Compare commits

..

29 Commits

Author SHA1 Message Date
Alexis Rolland
a16d488838 Merge branch 'main' into DynamicGroupSupport 2026-06-26 17:09:45 +08:00
AustinMroz
13b42d9b59 Ensure dynamic combo children cleanup state (#13073)
#12617 introduced a regression in Dynamic Combos. If two options have
child widgets of the same name (such as `bit_depth` on `Save Image
(Advanced)`), then widget state would be incorrectly shared between the
two widgets.

This is resolved by having removed widgets also delete their state.

There was previous interest in having widgets of this type keep state
when valid. This interest remains, but will require a more controlled
intentional implementation in the future.

Since the bit depth options on `Save Image (Advanced)` could potentially
be expanded in the future, this PR specifically adds a new devtools node
for testing with.

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-26 01:08:06 +00:00
Comfy Org PR Bot
e604c85b88 1.47.5 (#13166)
Patch version increment to 1.47.5

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-26 00:51:57 +00:00
AustinMroz
7ae3ad936c When dragging vue nodes, also drag reroutes (#12885)
`selectedItems` was being filtered to nodes and groups. Since no special
behaviour is being performed on groups, the 'move groups' code is
relaxed to instead 'move all non-node selected items'.
2026-06-26 00:11:48 +00:00
Comfy Org PR Bot
7b83228cdd 1.47.4 (#13083)
Patch version increment to 1.47.4

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-06-26 00:01:05 +00:00
Christian Byrne
d26f578c81 refactor(litegraph): delete dead onGetNodeMenuOptions, deprecate onBeforeChange (#12230)
Stacks on #12228. Part of the LGraph dead-hook cleanup per AUDIT-LG.9.

## What

- `LGraph.onGetNodeMenuOptions` — **deleted** (field + dispatcher in
`LGraphCanvas.getNodeMenuOptions`). Zero ecosystem consumers.
- `LGraph.onBeforeChange` — **deprecated, not deleted**. The field and
the dispatch in `LGraph.beforeChange()` are kept, but assigning a
handler now emits a one-time `warnDeprecated` nudging migration to
`LGraphCanvas.onBeforeChange`.

## Why onBeforeChange is preserved

The W2F-1 re-audit found `bmad4ever/ComfyUI-Bmad-DirtyUndoRedo` assigns
`app.graph.onBeforeChange = fn` (the listener-assignment pattern).
Deleting the field outright would silently turn those handlers into
no-ops. Keeping it as a deprecated shim preserves backward compatibility
during a grace period while signaling the intended replacement.

`onAfterChange` is untouched (so `BennyKok/comfyui-deploy`'s
`onAfterChange` wrapper keeps working). `LGraphCanvas.onBeforeChange`
remains a separate field, and the canvas dispatch chain
`this.canvasAction((c) => c.onBeforeChange?.(this))` is unchanged.

## Tests

`LGraph.test.ts` covers the shim: the assigned listener is still
invoked, the deprecation warning fires when used, and no warning fires
when no listener is assigned.

## Sequencing

- Stacks on #12228
- Sequences behind Alex's Phase B (#11939, #11811)

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-25 16:50:20 -07:00
Dante
cfbc378df3 feat(billing): align workspace Plan & Credits panel to DES-186 (FE-768) (#12761)
## Summary

Aligns the Settings ▸ Workspaces **Plan & Credits** panel to DES-186:
state-driven subtitle, team/personal header variants, design perks, and
a shared footer help bar. Stacked on FE-964 (#12734), which owns the
CreditsTile content.

## Changes

- **What**:
- Subtitle per design variants (Figma 3255-21472): Active → "Renews on
{date}", Ending → "Ends on {date}". `subscription_status: 'scheduled'`
falls back to the Active treatment — the facade exposes no
scheduled-plan target/date fields yet, so "Changes to {plan} on {date}"
cannot be rendered (template reserved in i18n-ready form; see Linear
note).
- Team-active header: plan name "Team" + seat-aware workspace total
(`seat_summary.total_cost_cents` from `/billing/plans`) as "$X USD /
mo"; the Next-month-invoice card reads the same computed so the two
can't disagree. Per-member tier price + "USD / mo / member" kept as the
plans-unresolved fallback.
- CTAs per design + designer annotation: "Manage billing" + "Change
plan" on a team plan, "Upgrade plan" on personal.
- Team perks (Figma 2993-14789): "Your plan includes everything in
**Pro**, plus:" (i18n-t, plan name emphasized) + invite members /
concurrent runs / shared credit pool / role-based permissions.
- Personal no-subscription variant (Figma 2993-14604): "Free · $0 USD /
mo" header + primary Subscribe CTA + "What's included:" with the
max-runtime perk → "10 min max runtime" (`subscription.maxDuration.free`
set to 10 min per DES 3253-16079).
- Footer help bar (Learn more / Partner Nodes pricing table / Message
support / Invoice history) extracted into `SubscriptionFooterLinks.vue`,
shared by workspace + legacy panels; new surface-specific key for
"Partner Nodes pricing table".
- **Breaking**: none.

## Review Focus

- Seat-aware price source (`currentPlan.seat_summary.total_cost_cents`)
vs per-member fallback — fixture locks $320 vs $80.
- `'scheduled'` → Active fallback (adapter-level test in
`useWorkspaceBilling.test.ts`).
- Free-state perk copy: `subscription.maxDuration.free` set to **10
min** per DES 3253-16079 (design confirmed; was 30 min).
- Free-state overflow (`⋯`) button intentionally omitted: the only
existing menu item (Cancel Subscription) doesn't apply to a free plan.

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Plan & Credits half; Members invite UI ships in #12759)

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session). Team and free states use client-side API stubs (XHR-level for
`/api/billing/*` + `/api/workspace/*`, fetch-level for legacy
`/customers/*`) since the test workspaces are unsubscribed;
personal-active rows are real account data.

| State | Before (FE-964 base) | After (this PR) |
|---|---|---|
| Team plan — active | <img width="400" alt="before: Pro, $100
USD/mo/member, Renews, Manage Payment/Upgrade Plan"
src="https://github.com/user-attachments/assets/622a9a27-1875-4c08-92b7-9e43a8067c59"
/> | <img width="400" alt="after: Team, $300 USD/mo, Renews on, Manage
billing/Change plan, design perks"
src="https://github.com/user-attachments/assets/adb0f767-d508-4455-ad8e-ee2d6ac419dc"
/> |
| Team plan — ending (cancelled) | <img width="400" alt="before: Expires
Jul 10, 2026"
src="https://github.com/user-attachments/assets/cb4bb978-4e8b-4372-8ecc-7265f477a828"
/> | <img width="400" alt="after: Ends on Jul 10, 2026"
src="https://github.com/user-attachments/assets/5466b99a-d016-4eba-a60f-99b87bd2693e"
/> |
| Personal — active (real data) | <img width="400" alt="before: Renews
Mar 10 2027, Manage Payment"
src="https://github.com/user-attachments/assets/34018400-930b-4147-bd0d-398fb4d159ee"
/> | <img width="400" alt="after: Renews on Mar 10 2027, Manage billing"
src="https://github.com/user-attachments/assets/87af7fa7-28cf-4cdf-b9a2-158b2a6eb979"
/> |
| Personal — no subscription | <img width="400" alt="before: generic
not-on-a-subscription prompt"
src="https://github.com/user-attachments/assets/7ee5b36e-1e07-4630-a9b7-680f4fad349b"
/> | <img width="400" alt="after: Free $0 USD/mo header + Subscribe +
What's included"
src="https://github.com/user-attachments/assets/1eda1791-e3de-46c3-bb69-df0365545211"
/> |



---

## Perk descriptions — designer QA (DES `3253-16079`)

CDP-verified the Plan & Credits **perk list** per current plan against
Figma. Captured at the full 1280px settings layout (width fix: #12849;
the perk text/content is identical at the current 960px).

| Plan | "Includes" header | Perks shown |
|---|---|---|
| Free (no subscription) | What's included: | 10 min max runtime |
| Personal — Pro | Your plan includes: | 1 hr max run duration · RTX
6000 Pro (96GB VRAM) · Add more credits whenever · Import your own LoRAs
|
| Team | Your plan includes everything in **Pro**, plus: | Invite
members · Members can run workflows concurrently · Shared credit pool
for all members · Role-based permissions |

**Free — Figma `3253-16079` (left) vs implementation (right):**

| Figma | Implementation |
|---|---|
| <img width="480" alt="Figma DES 3253-16079 free settings"
src="https://github.com/user-attachments/assets/cfe79570-f3ba-4627-a8eb-348b9158f6ac"
/> | <img width="480" alt="App free settings — What's included: 10 min
max runtime"
src="https://github.com/user-attachments/assets/d89898e4-d819-486c-9b4f-c2fd61916783"
/> |

**Personal — Pro:**

<img width="640" alt="App personal Pro — Your plan includes"
src="https://github.com/user-attachments/assets/adc2fd9f-d249-469f-b947-1ec8f674cbb0"
/>

**Team:**

<img width="640" alt="App team — Your plan includes everything in Pro,
plus"
src="https://github.com/user-attachments/assets/e7378067-11a2-411b-b37b-98c8aecb82b1"
/>

Open items (design):
- Free perk now reads **"10 min max runtime"**
(`subscription.maxDuration.free` set to 10 min) per Figma `3253-16079` —
 applied in this PR.
- Personal-plan perk **stacking** (show lower-tier perks under the
current tier) is an unresolved Figma thread on this node — not
implemented.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 23:19:35 +00:00
Wei Hai
db085eb7a1 feat(auth): Cloudflare Turnstile on email signup (origin-gated) (#12924)
Adds a Cloudflare Turnstile widget to the email/password signup form
(web + desktop). The frontend renders the widget and attaches its token
to the signup request; the verification decision is made server-side.

## Design — config-driven, no origin sniffing

* The widget renders **iff** the `signup_turnstile` mode is `shadow` or
`enforce` **and** a `turnstile_sitekey` is present — both delivered via
cloud remote config. OSS / local builds receive no remote config, so it
never renders. Gating is a pure `isTurnstileEnabled(mode, siteKey)`; an
unknown mode normalizes to `off`.
* Submit is blocked only in **enforce**; **shadow** never blocks.
* The token is sent as `turnstile_token` (snake_case, optional) on the
customer-creation request.
* **OAuth** never renders the widget or sends a token (federated
providers are exempt).

## Behavior

* **Decision is server-side** — the frontend only renders the widget and
attaches the token; the backend verifies it and decides allow/block.
* **Mode-driven** — `off` (no-op) / `shadow` (render + attach, never
blocks) / `enforce` (blocks submit until solved).
* **Config-gated** — no `isCloud`/origin check in the client; the widget
is driven purely by the presence of the mode flag + sitekey in remote
config.
* **Fail-safe to off** — an unknown/missing mode or a missing sitekey
resolves to "don't render", so the feature is a no-op until both are
configured.
* The sitekey is a public, client-side value delivered per environment
via remote config; in dev it falls back to Cloudflare's always-pass test
sitekey.

## Files

New: `config/turnstile.ts`, `composables/auth/useTurnstile.ts` (+ test),
`composables/auth/turnstileScript.ts`,
`components/dialog/content/signin/TurnstileWidget.vue`. Edited:
`SignUpForm.vue`, `SignInContent.vue`, `useAuthActions.ts`,
`authStore.ts` (+ test), `remoteConfig/types.ts`,
`locales/en/main.json`.

## Flow

```mermaid
sequenceDiagram
    actor U as User
    participant FE as Signup form
    participant CF as Cloudflare Turnstile
    participant API as Backend signup API

    Note over FE: renders only when mode is shadow or enforce<br/>and a sitekey is present
    U->>FE: open email/password signup
    FE->>CF: load widget with sitekey
    CF-->>U: challenge (usually invisible)
    U-->>CF: solve
    CF-->>FE: token (single-use, short-lived)
    U->>FE: submit
    FE->>API: signup request with turnstile_token
    Note over API: verifies the token server-side and<br/>decides allow/block (shadow never blocks)
    API-->>FE: allowed, or blocked in enforce
```

## Rollout

Config-driven and a no-op until enabled:

1. **Merge + deploy** the FE — no visible change while the mode is `off`
/ no sitekey.
2. **Set** the `turnstile_sitekey` in remote config per environment.
3. **`signup_turnstile=shadow`** — the widget renders and attaches the
token; the server observes and never blocks.
4. → **`enforce`** — the FE blocks submit until the challenge is solved.

Kill switch: set the mode back to `off` and the widget stops rendering.

## Refactor: shared script loader

The Turnstile script loader was extracted to
`utils/loadExternalScript.ts` (`createScriptLoader`) and now also backs
the existing Typeform embed loader, removing duplicated
singleton/timeout/cleanup logic. Minor behavioral change: when a
matching `<script>` tag already exists in the DOM, the loader polls for
the global to become ready instead of attaching a `load` listener (which
may have already fired).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 23:03:48 +00:00
Simon Pinfold
48d429bd13 Allow Glary Bot through CLA (#13149)
## Summary

- Add `Glary Bot` to the CLA Assistant allowlist.

## Context

PR #13146 is blocked because its commit author is `Glary Bot
<bot@glary.dev>`, which GitHub does not resolve to a GitHub user. The
CLA action checks the unresolved commit author name after failing to
find a linked GitHub account, so the existing `*[bot]` GitHub App
allowlist does not apply.

## Validation

- Ran `git diff --check`.
2026-06-25 22:48:19 +00:00
AustinMroz
6eaad99502 Add center dividing line to image compare node (#13132)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/37afb473-161c-4350-881e-0ea908e28777"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/d5acca61-3687-4c15-8029-ef2c88a06944"
/>|
2026-06-25 20:38:59 +00:00
Alexander Brown
2ced8a25d4 Add assertive profile to review settings (#13120)
## Summary
Rabbit too nice.
2026-06-25 20:28:49 +00:00
AustinMroz
9209a4b923 Add long widget values to tooltips (#12864)
If a widget value is long (> 10 characters) and on a known single-line
widget (`number`, `combo`, or `string), then the widget's full value
will be added to the tooltip.

Additionally, margins on combo widgets are slightly tweaked so more of
the text displays before truncation occurs.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/fefd76e9-6511-4e98-80f6-030a6dc34fb8"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/0cbc100d-066e-4272-afe9-795e56c12353"
/>|
2026-06-25 19:25:02 +00:00
AustinMroz
90cb8021df Update CLA allowlist (#13141)
It's `github-actions` without the `[bot]`. This was blocking every PR
that contained updated browser test expectations.

Additionally, the action already included an allow list for every
account ending in `[bot]`. This made half the entries redundant.
2026-06-25 18:30:48 +00:00
jaeone94
5a01c5b3b4 Remove redundant Enter Subgraph action from Errors tab (#13136)
## Summary

Remove the redundant **Enter Subgraph** action from Errors tab node
cards. This button should not be part of the updated Errors tab design;
it remained from the previous implementation and its removal was missed
when the new interaction model was introduced.

## Changes

- **What**: Removed the Errors tab `Enter Subgraph` button from
`ErrorNodeCard`, along with the `enterSubgraph` event plumbing in
`TabErrors`.
- Removed the now-unused `useFocusNode().enterSubgraph()` helper path,
since the Errors tab no longer has a separate subgraph-only action.
- Removed the `ErrorCardData.isSubgraphNode` flag and its population in
`useErrorGroups`, because it only existed to decide whether to show this
button.
- Removed the Storybook story and unit-test expectations that were
specifically tied to the removed button/flag.
- Removed the now-unused English `rightSidePanel.enterSubgraph` i18n
entry. Non-English locale files are intentionally left untouched per the
repo's localization update policy.

## Why

The Errors tab already has a **Locate node on canvas** action. For
errors inside subgraphs, that action navigates into the relevant
subgraph and centers the target node on the canvas. The removed **Enter
Subgraph** action was therefore a weaker duplicate: it entered the
subgraph and fit the view, but did not provide the same direct
target-node positioning.

Keeping both actions made the card UI more crowded and exposed two very
similar navigation paths with overlapping intent. The updated design
should only keep the more useful locate action, so this PR removes the
stale duplicate surface rather than adding another hidden/negative
assertion around it.

## Review Focus

Please verify that this only removes the Errors tab-specific action. The
normal node footer/canvas subgraph navigation behavior remains
untouched.

Validation run locally:

- `pnpm exec vitest run
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format:check`
- `pnpm knip`


## Screenshot 

Before
<img width="335" height="595" alt="스크린샷 2026-06-25 오후 5 33 37"
src="https://github.com/user-attachments/assets/545f80e9-68bb-45ef-a4da-0a41012269f6"
/>

After
<img width="344" height="591" alt="스크린샷 2026-06-25 오후 5 34 24"
src="https://github.com/user-attachments/assets/7c1f1bf6-c5fd-4a43-9b5c-1392246070a8"
/>
2026-06-25 11:25:49 +00:00
Dante
e3049e7c31 feat(billing): single billing path — collapse personal/team dispatch to flag-only (B1 / FE-966) (#12953)
## What this does

Collapses the personal-vs-team billing dispatch so it keys ONLY on the
build/flag (`teamWorkspacesEnabled ? 'workspace' : 'legacy'`). Personal
now flows through `useWorkspaceBilling` (`/api/billing/*`), same as team
("personal plan = single-seat workspace"). This converges status /
balance / subscribe / preview / cancel / portal in one change.

Dispatch sites collapsed:
- `useBillingContext.ts` — `type` computed: dropped the
`store.isInPersonalWorkspace` branch → flag-only.
- `useBillingContext.ts` — D3 subscription→store mirror watch: dropped
the `isInPersonalWorkspace` early-return guard so personal also mirrors
into the workspace store.
- `useSubscriptionDialog.ts` — `useWorkspaceVariant` compound predicate
→ flag-only (personal + flag-on now uses the workspace required-dialog
variant).
- `SubscriptionPanel.vue` — already flag-only
(`v-if="teamWorkspacesEnabled"`); no change needed.

## Kept (Risk #6)

- The ~11 raw `workspace.type === 'personal'` checks in
`teamWorkspaceStore.ts` — workspace-TYPE membership logic
(can-delete/leave, fetch-members, switcher), NOT billing dispatch.
Untouched.
- `useLegacyBilling` / `useSubscription` / authStore billing methods
kept intact for the flag-OFF (OSS/Desktop) path.

## Flag-off unchanged

Flag-OFF (OSS/Desktop) still selects `legacy` (`/customers/*`). Verified
by unit test.

## Tests

- `useBillingContext`: flag-ON → personal selects `workspace`; flag-OFF
→ `legacy`; D3 mirror now fires for personal under flag-on.
- `useSubscriptionDialog`: flag-ON → workspace required-dialog variant
for personal; flag-OFF → legacy personal variant.

## Follow-up (deferred, not in this PR)

Post-flip cutover deletion of `useLegacyBilling`-only components:
`PricingTable.vue`, `SubscriptionPanelContentLegacy.vue`,
`TopUpCreditsDialogContentLegacy.vue`, `CurrentUserPopoverLegacy.vue`,
`subscriptionCheckoutUtil.ts`, `useSubscriptionCancellationWatcher.ts`.

- Fixes part of FE-903 (B1)
2026-06-25 06:59:00 +00:00
Dante
87e84e7280 feat(billing): inline Invite-your-team block on team-upgrade success (FE-965 / DES-394) (#12954)
Renders the inline **"Invite your team"** block in the team variant of
FE-934's "You're all set" success card
(`SubscriptionSuccessWorkspace.vue`), so a buyer can invite teammates
right after a team-plan upgrade (DES-394, Figma 3084-18651).

- New shared `InviteMembersForm.vue` (chips / `TagsInput` multi-email
form); seats capped via `useBillingContext.getMaxSeats(tierKey)`; submit
via `workspaceApi.createInvite`.
- Team upgrades only — personal / single-seat plans show the plain
success card; gated on `teamWorkspacesEnabled` + a team plan.
- `workspace_invite_sent` telemetry distinguishing a post-upgrade invite
from a Settings invite; success-card i18n + Storybook story.

**Stacked on FE-934 #12975** (`jaewon/fe-934-team-subscribe-wire`, the
success-card host). The PR base is that branch, so this diff is FE-965's
delta only. Re-using the same form in the Settings invite dialog is out
of scope here (belongs with FE-768 / a follow-up).

## Screenshots

| Team upgrade — invite block | Personal / non-team — no invite block |
|---|---|
| <img
src="https://github.com/user-attachments/assets/be5450fe-2b83-46bd-afbc-00e6d33590b7"
width="420" /> | <img
src="https://github.com/user-attachments/assets/a91909c7-7629-42ef-80b6-45fdb070a0e8"
width="420" /> |

Storybook: `Components/SubscriptionCheckoutSteps` →
`TeamSuccessWithInvite` (with block) / `SuccessAllSet` (without).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 05:54:04 +00:00
Dante
67009dcda2 feat(workspace): promote/demote members via Change role menu (FE-770) (#12782)
Promote / demote workspace members ↔ owners in Settings ▸ Members, per
[DES-222 / Figma
2993-15512](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=2993-15512)
and the [permissions section
3343-22966](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3343-22966).

- Fixes
[FE-770](https://linear.app/comfyorg/issue/FE-770/promote-demote-workspace-members-owners-settings-members)
- Stacked on #12759 (`jaewon/fe-768-members-invite-ui`)

## Changes

- Per-member row (…) menu → **Change role** submenu (Owner / Member,
current role check-marked) + existing **Remove member**, replacing the
shared PrimeVue `Menu` with the Reka `DropdownMenu`/`DropdownItem`
(submenu opens right of parent, flips on collision; scalable for future
roles).
- **Make [name] an owner?** / **Demote [name] to member?** confirm
dialogs (single `ChangeMemberRoleDialogContent`, copy 1:1 from Figma).
- `workspaceApi.updateMemberRole` → `PATCH
/api/workspace/members/:userId {role}` +
`teamWorkspaceStore.changeMemberRole` (local role map update; Role
column re-sorts).
- **Original-owner guards** (Figma annotations): creator pinned to the
top of the list, no row actions for anyone on that row; own row also has
no actions. Creator inferred as earliest `joined_at` until BE exposes an
explicit flag (tracked as the FE-770 BE blocker — same applies to the
endpoint itself, which does not exist yet; UI is wired to the proposed
contract).
- `DropdownMenu` raised to `z-3000` so the row menu sits above the
Settings modal (the Reka popper wrapper copies the content's computed
z-index; static `z-1700` lost to dialogs in the `@primeuix` modal
sequence). Also drops the always-rendered icon slot in `DropdownItem` so
icon-less items (Change role / Remove member) align flush-left.

## User stories verified

Viewer = an **owner** (promoted, not the workspace creator), so the
creator guard and the self guard are exercised separately.

| # | Click → action → expected |
| --- | --- |
| US1 | Member row (…) → menu shows **Change role ›** + **Remove
member** |
| US2 | Hover **Change role** → Owner / Member submenu, **current role
check-marked** |
| US3 | Click the current role (✓) → no dialog, no PATCH (no-op) |
| US4 | Member row → **Owner** → "Make {name} an owner?" + "They'll have
the same access as you — managing members, billing, and workspace
settings." + Cancel / **Make owner** |
| US5 | **Cancel** (or ✕) → dialog closes, role unchanged, no PATCH |
| US6 | **Make owner** → `PATCH /api/workspace/members/:id
{role:'owner'}` → Role column → Owner, row **re-sorts under the
creator**, "Role updated" toast, the promoted row keeps its (…) menu |
| US7 | Promoted owner row → **Member** → "Demote {name} to member?" +
"They'll lose admin access." → **Demote to member** → Role column back
to Member |
| US8 | **Creator row (earliest joined) has no (…) button** — even for
another owner |
| US9 | **Own (You) row has no (…) button** — even when not the creator
|
| US10 | PATCH 500 → "Failed to update role" toast, **dialog stays
open**, role unchanged |
| US11 | Viewer with `member` role → no row actions anywhere |
| US12 | **Remove member** → existing FE-768 "Remove this member?"
dialog |

## Tests

Each user story is covered by automated tests and confirmed by a manual
CDP pass driving the real cloud app (mocked auth + boot +
workspace/billing API).

| Story | Unit / Component | E2E (Playwright) | CDP (live app) |
| --- | :---: | :---: | :---: |
| US1 row menu shows Change role + Remove member |  |  |  |
| US2 submenu checkmark follows current role |  |  |  |
| US3 picking the current role is a no-op |  |  |  |
| US4 promote dialog copy (Make owner) |  |  |  |
| US5 Cancel leaves role unchanged, no PATCH |  |  |  |
| US6 Make owner → PATCH, re-sort under creator, toast, stays demotable
|  |  |  |
| US7 demote dialog (Demote to member) → role reverts |  |  |  |
| US8 creator row has no (…) menu |  |  |  |
| US9 own (You) row has no (…) menu |  |  |  |
| US10 PATCH 500 → error toast, dialog stays open |  |  |  |
| US11 member-role viewer sees no row actions |  | — | — |
| US12 Remove member → FE-768 remove dialog |  |  |  |

| Layer | File | What it covers | Result |
| --- | --- | --- | --- |
| E2E (`@cloud`) |
`browser_tests/tests/dialogs/memberRoleChange.spec.ts` | 3 tests — guard
rows (US1/US8/US9/US12), promote→re-sort→demote round trip (US3–US7),
failed PATCH (US10). FE-964 boot pattern: `CloudAuthHelper` +
remote-config flag mock + stateful route mocks capturing PATCH args.
Reka submenu driven via `ArrowRight` (synthetic hover doesn't open it).
| 3 / 3 green |
| Component | `ChangeMemberRoleDialogContent.test.ts` | promote/demote
copy, confirm → store + success toast + close, error keeps dialog open,
cancel | green |
| Component | `MembersPanelContent.test.ts` | creator/self rows hide the
menu (US8/US9), member-viewer gating (US11) | green |
| Composable | `useMembersPanel.test.ts` | menu factory
labels/checkmarks/commands, same-role no-op, creator pin in
`sortMembers`, `isOriginalOwner` | green |
| Store | `teamWorkspaceStore.test.ts` | `changeMemberRole`
success/failure, `originalOwnerId` inference | green |
| CDP live | full cloud app on `local.comfy.org` (mocked auth + boot) |
promote→re-sort→demote round trip with PATCH applied to mock state,
guard rows, submenu checkmark, dialog copy, menu/dialog z-index above
Settings, forced PATCH 500 → error toast | verified |

⚠️ Merge-gated on the BE role-change endpoint (no `PATCH
/workspace/members/:userId` in cloud OpenAPI as of 2026-06-10; see
FE-770 BE-blocker comment).

## Screenshots (local dev, workspace/billing API stubbed; vs Figma
2993-15512)

| Members (before) | Change role submenu |
| --- | --- |
| <img alt="members"
src="https://github.com/user-attachments/assets/686fec86-fcb5-4942-a745-50f367022ab0"
/> | <img alt="submenu"
src="https://github.com/user-attachments/assets/d6adeea8-7001-4c8d-91b7-f5bfc47a50d6"
/> |

| Promote dialog | After promote (Jane → Owner, still demotable) |
Demote dialog |
| --- | --- | --- |
| <img alt="promote"
src="https://github.com/user-attachments/assets/af638cde-2fd6-4c37-b203-78801eeb2785"
/> | <img alt="after"
src="https://github.com/user-attachments/assets/f47dc7af-6b1b-422c-8a9a-5ec889b9af11"
/> | <img alt="demote"
src="https://github.com/user-attachments/assets/9a861d04-a23b-4cd4-bc54-1ed3a66c6429"
/> |
2026-06-25 05:04:48 +00:00
Dante
026b2c4795 feat(billing): unify credits into a facade-driven CreditsTile (FE-964) (#12734)
## Summary

### AS IS 
<img width="1340" height="798" alt="Screenshot 2026-06-10 at 12 22 36
AM"
src="https://github.com/user-attachments/assets/61636fa3-e80c-427b-855b-499e1eca67da"
/>

### TO BE

<img width="1301" height="793" alt="Screenshot 2026-06-10 at 12 22 39
AM"
src="https://github.com/user-attachments/assets/62d9f5a6-da92-45df-94e7-cd3c244249f9"
/>

### Empty states ([added to DES-247 on
2026-06-11](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=3349-29750))

| 0 monthly credits | 0 credits |
| --- | --- |
| <img alt="credits-empty-monthly"
src="https://github.com/user-attachments/assets/b3c55d3b-79b0-47b1-9795-c8bf69d5efe2"
/> | <img alt="credits-empty-all"
src="https://github.com/user-attachments/assets/919081d6-64e1-483b-9c04-6b085243ebc1"
/> |

Consolidate the divergent Settings credits surfaces into one
facade-driven **CreditsTile**, implementing the DES-247 redesign so
personal and team modes always render the same balance from
`useBillingContext`.

## Changes

- **What**:
- New `CreditsTile.vue` — total + `remaining`, a stacked
monthly/additional progress bar, colored breakdown rows (`Monthly
(refills …)` / `Additional`), refresh, and a permission-gated *Add
credits* / *Upgrade to add credits* action. Owns the post-checkout
(`focus` / `pending_topup`) balance refresh.
- Extracted the duplicated inline credits card out of
`SubscriptionPanelContentWorkspace.vue` **and**
`SubscriptionPanelContentLegacy.vue` onto the shared tile.
- Replaced `LegacyCreditsPanel.vue` (read `authStore.balance` directly)
with `CreditsPanel.vue` routed through the tile; repointed
`useSettingUI` and deleted the legacy panel.
- `creditsProgress.ts` pure helper for the bar math + numeric credit
getters on `useSubscriptionCredits`.
  - i18n keys for the unified tile labels.
- DES-247 responsive variants via CSS container queries: below ~350px
tile width the `{used} used` label, `remaining` suffix, and breakdown
subtitle drop and the additional-credits value stacks under its label;
below ~230px the monthly summary compacts (`105K left of 200K`).
Additional-credits tooltip copy aligned with the updated design (per
design feedback).
- Empty states (added to DES-247 via Slack on 2026-06-11, low priority):
once the monthly allowance is depleted, an info notice renders under the
total (`Monthly credits are used up. Refills {date}` / `You're now
spending additional credits.`), the monthly bar section dims to 30%
opacity, and an `IN USE` pill marks *Additional credits*; once
everything is depleted the notice switches to `You're out of credits.
Credits refill {date}` and *Add credits* swaps to the `inverted`
(filled-white) Button variant. Gated on a loaded balance so the notice
never flashes while fetching.
- **Dependencies**: Stacked on **#12622 (FE-904 / B2)** for the facade
`tier` / `renewalDate` fields — base this PR against that branch;
retarget to `main` once FE-904 merges.

## Review Focus

- The tile reads everything from the facade (`balance.*Micros` as cents
→ credits, `subscription.tier`/`renewalDate`), so legacy and workspace
modes share one source.
- Monthly allowance still comes from `getTierCredits` (hardcoded tier
nominal). With real data the monthly *remaining* can exceed the nominal
(rolled-over credits), so the bar clamps to a full segment — same
semantics as the prior `{monthly} / {planTotal}` display; the canonical
allowance is a BE-1047 follow-up.
- `LegacyCreditsPanel` deletion: `CreditsPanel.vue` retains the
usage-history table + help links and reads the facade.

## Testing

- Unit/component (36 green): `CreditsTile.test.ts` (render, zero-state,
free-tier, permission gating, add-credits, mount+manual refresh, plus
the empty states: depletion notice copy, `IN USE` badge, `inverted`
button when fully out, no-flash-while-loading guard),
`creditsProgress.test.ts` (clamping/stacking math),
`useSubscriptionCredits.test.ts` (`*_micros`-as-cents), and
`SubscriptionPanel.test.ts` updated for the extracted tile.
- E2E (`@cloud`): `browser_tests/tests/dialogs/creditsTile.spec.ts`
boots the cloud app against mocked Firebase auth + stubbed boot
endpoints (no backend) and asserts the tile's total / progress bar /
monthly+additional breakdown / add-credits in Settings ▸ Workspace ▸
Plan & Credits, then resizes to a narrow viewport and asserts the
responsive variant (labels hidden, compacted `11K left of 21K`). A
second test boots with a drained monthly balance (0-monthly notice + `IN
USE` badge), then re-mocks a fully drained balance and refreshes the
tile in place to assert the out-of-credits state. Both pass locally
against a cloud dev server; runs in the `cloud` CI project. Drives a raw
page because the shared `comfyPage` fixture expects the OSS devtools
backend.
- Screenshot-verified the tile at the three DES-247 reference widths
(448 / 235 / 204px) against the Figma Responsiveness section — 1:1.
- Verified live in the running app (Settings ▸ Workspace ▸ Plan &
Credits) against the authenticated backend — renders 1:1 with DES-247.
The empty-state screenshots above were captured the same way
(authenticated app, real Pro subscription, balance endpoint stubbed to
the depleted values via CDP).
- `pnpm typecheck` / `typecheck:browser` / `lint` / `knip` green.

Implements FE-964 (DES-247).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:44:59 +00:00
Dante
d60260ac3c feat(billing): team-plan subscribe + checkout/confirm screen redesign (FE-934) (#12975)
## Summary

Two related streams of FE-934 checkout/billing work on this branch:



https://github.com/user-attachments/assets/af629def-543e-4bcd-894d-b35aa032fe0a



1. **Team-plan subscribe** wired to the BE-1254 credit-stop contract
(replaces the "coming soon" toast stub).
2. **Checkout / confirm screen redesign** aligned to the updated DES
mockup — yearly pricing display, dialog navigation, and the plan-change
confirm.

<img width="1078" height="427" alt="team subscribe"
src="https://github.com/user-attachments/assets/2b5f7192-3c91-4e2d-b495-832ca5a26657"
/>

## Changes

### Team-plan subscribe (credit-stop contract)

- Team subscribe sends `{ plan_slug: team_per_credit_monthly |
team_per_credit_annual, team_credit_stop_id, billing_cycle }` to `POST
/api/billing/subscribe` and handles the response like the personal path
(`subscribed` → success, `needs_payment_method` → payment URL,
`pending_payment` → poll). Slider stops come from `GET
/api/billing/plans → team_credit_stops` via `mapApiTeamCreditStops`,
falling back to the hardcoded DES-197 stops so OSS / pre-deploy still
render. `preview-subscribe` is unchanged — the team confirm step is
display-only.
- **Internal API change**: `workspaceApi.subscribe(planSlug, returnUrl?,
cancelUrl?)` → `subscribe(planSlug, options?: SubscribeOptions)`; the
billing facade (`useBillingContext` / `useWorkspaceBilling` /
`useLegacyBilling`) and callers were updated to match.

### Checkout / confirm screen redesign (DES mockup)

- **Yearly confirm**: headline is now the ÷12 monthly-equivalent with a
`{total} Billed yearly` (yearly) / `Billed monthly` (monthly) subtitle;
credits show `Each year credits refill to` (×12) for yearly; `Starting
today` → `Starts today`. The team confirm now receives the active
billing cycle (the subtitle was missing because it wasn't passed), and
the redundant header credits/month line was dropped.
- **Navigation**: the pricing table stays mounted (`v-show`, not `v-if`)
so the plan / billing-cycle / credit-stop selection survives a round
trip to the confirm step and back; **Backspace** mirrors the back arrow
(ignored while an input/textarea/contenteditable is focused).
- **Plan-change confirm**: rewritten from the 2-column current→new
comparison to the **single-plan layout**, branching on
`previewData.is_immediate` — immediate upgrades show prorated line items
(`new_plan.price_cents − cost_today_cents` = credit) + upfront (yearly)
/ monthly credit refill + a prorated total ("Confirm upgrade");
scheduled downgrades show `Starts {date}`, $0 due today, and an "After
that" block ("Confirm change").
- **Storybook**: `SubscriptionCheckoutSteps` stories for each new-sub /
upgrade / downgrade variation (props-driven, no API).

## Review Focus

- **Merge gate (team subscribe)** —  **resolved**: BE-1254 is now
merged in cloud `main`. The live `GET /api/billing/plans` returns
`team_credit_stops` (`TeamCreditStop` struct + validation in
`common/billing/catalog/catalog.go`, served for every workspace,
asserted by the `billing_credit_stops_contract` smoke test). The gate is
lifted.
- **Fallback safety**: with the contract live, the hardcoded DES-197
stops are now purely the OSS / pre-deploy fallback — they render only
when the API doesn't supply `team_credit_stops`. In that window a real
subscribe is still impossible (no stop `id`) and surfaces a
`teamPlan.unavailable` toast. Open question retained: hide/disable the
team CTA in that window instead of toasting?
- **Plan-change scope**: the single-plan confirm redesign covers
**personal** changes (the real `previewSubscribe` path). **Team** plan
changes route through the display-only team confirm and aren't wired to
`previewSubscribe` (BE left `PreviewSubscribeRequest` as `plan_slug`
only) — team-change proration is a follow-up.
- **Dead locale keys**: the old 2-column transition keys
(`everyMonthStarting`, `youllBeCharged`, `proratedRefund`,
`proratedCharge`, `creditsRefillTo`, `switchToPlan`, `starting`, `ends`,
`confirmPlanChange`) are now unused — can be removed in a follow-up
cleanup.
- **Out of scope**: the success "Your change is scheduled" variant from
the mockup.
- Based on `fe-934-unified-pricing-table`; base + `main` merged in to
resolve conflicts (PR is now mergeable).

## Verification

- Confirm / transition component tests green
(`SubscriptionAddPaymentPreviewWorkspace`,
`SubscriptionTransitionPreviewWorkspace`); oxlint / oxfmt clean locally;
full typecheck runs in CI.
- Each variation viewable in Storybook →
`Components/SubscriptionCheckoutSteps`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-25 04:27:44 +00:00
Dante
0c89f5a3a7 feat: route cloud auth through the single Cloud JWT under unified_cloud_auth (FE-950) - step 3 (#12708)
## Summary

Behind `unified_cloud_auth` (default OFF), flip the two token accessors
so every cloud request rides the single Cloud JWT minted in PR2. This is
the consumer-flip phase of FE-950: PR2 built the dormant `unifiedToken`
slot; this PR makes consumers read it — and surfaces the
permanent-auth-failure path that the flip turns from graceful
degradation into a hard stop.

Stacked on #12704 (PR2), now merged; base is `main`.

## Changes

- **What**:
- `getAuthHeader()` — flag ON returns `{ Authorization: Bearer
<unifiedToken> }` (or `null` if unminted), with **no** Firebase/API-key
fallback. Flag OFF keeps the exact workspace → Firebase → API-key
cascade.
- `getAuthToken()` — flag ON returns the unified Cloud JWT (or
`undefined`); flag OFF keeps workspace → Firebase.
- Both accessors are the single seam every cloud consumer already routes
through, so the flip propagates automatically with **no edits** to
`fetchApi` (`scripts/api.ts`), `/customers/*` (authStore),
`workspaceApi`, the WebSocket (`api.ts:568`), or backend-node auth
(`app.ts:1593`).
- **Surface permanent auth failures** (answers @pythongosssss's review
on PR2). Under the flag there is no Firebase fallback, so a silent
`clearUnifiedContext()` wipe would strand every cloud request until
manual re-login — unlike the legacy path, which degrades to the Firebase
token. `refreshUnified()` and `mintAtLogin()` now emit a user-facing
error toast (keyed by error code off the existing `workspaceAuth.errors`
i18n) on the permanent codes (`ACCESS_DENIED` / `WORKSPACE_NOT_FOUND` /
`INVALID_FIREBASE_TOKEN` / `NOT_AUTHENTICATED`). `mintAtLogin()` now
resolves `false` on a permanent failure instead of rejecting an
unhandled `void`ed promise. Transient failures stay silent (proactive
refresh still retries). Also trims the verbose unified-lifecycle
comments flagged in review.
- **Breaking**: None. Flag OFF is byte-for-byte the current cascade.

## Review Focus

- **Single token, no fallback under the flag.** Tests assert
`getAuthHeader`/`getAuthToken` return only the unified token and never
call `getIdToken` or the API-key store; they return `null`/`undefined`
(not a fallback) when the token is unminted.
- **Surfacing, not recovery.** This PR makes the terminal state
*visible* (toast); the existing router auth-guard still redirects to
login on the next navigation. Active recovery (automatic re-mint on 401)
stays in the deferred safety-net PR so the toast is never a dead-end
"please re-login" with the fix one PR away.
- **Flag-OFF parity.** The full existing cascade suite runs with
`unifiedCloudAuthEnabled = false` (the `beforeEach` default) and stays
green.

## Deferred (intentional)

- **`acceptInvite` is left unchanged — still Firebase-authed.** It is
the one cloud call that intentionally keeps the raw Firebase token,
because the invite is accepted *before* the user is a member of the
target workspace. Promoting it to the unified Cloud JWT first needs a
quick check that `POST /invites/:token/accept` accepts a personal-scoped
Cloud JWT for a not-yet-member; deferred until that is verified.
`getFirebaseAuthHeader` / `getFirebaseAuthHeaderOrThrow` stay defined
(their removal belongs to the later cleanup ticket, FE-951). No
`workspaceApi.ts` change in this PR.
- **The reactive 401 re-mint + retry safety net is a follow-up.** A
clean place to intercept a `401` and re-mint once does not exist yet:
cloud requests use raw `fetch` (`/customers/*`, `/auth/token`) plus
several independent axios clients (`workspaceApi`,
`customerEventsService`, registry, manager), with no shared response
interceptor. PR2's `remintUnifiedOnce()` primitive is ready, and the
proactive buffer-based refresh (`refreshUnified`) already covers the
common token-*expiry* case, so this cross-cutting safety net (plus
deciding whether the surfacing toast escalates to a guided re-login CTA
once remint exists) lands in its own focused PR before any production
rollout. Note this is orthogonal to the surfacing above: proactive
refresh prevents expiry; it cannot prevent *revocation*, which is
exactly what triggers the now-surfaced permanent-error path.

## Tests

- Extended `authTokenPriority.test.ts`: flag-ON `getAuthHeader` returns
only the unified JWT (Firebase + API-key + workspace untouched) and
`null` when unminted; flag-ON `getAuthToken` returns the unified JWT
(not Firebase) and `undefined` when unminted. Existing cascade tests
prove flag-OFF parity.
- Added to `useWorkspaceAuth.test.ts` (red-green + regression lock): a
permanent refresh error toasts the **correct i18n key for each of the
four permanent codes** (`it.for` over 403/404/401 + a
lost-Firebase-token `NOT_AUTHENTICATED` case) and clears the slot; a
permanent login-mint error toasts and resolves `false`. Negative guards
prove the surfacing is **error-only and flag-scoped**: a transient (5xx)
refresh does **not** toast and keeps the slot, a **successful** re-mint
does not toast, and the unified lifecycle **never toasts when the flag
is OFF** (even against a rejecting backend).

## Red-Green Verification

| Commit | CI | Purpose |
|--------|-----|---------|
| `test: cover permanent unified-auth error surfacing` | 🔴
Red
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27455949404))
| Proves the tests catch the silent-failure gap |
| `fix: surface permanent unified-auth errors instead of failing
silently` | 🟢 Green
([run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27456200098))
| Proves the surfacing resolves it |

Part of FE-950 (single Cloud-JWT provider at login, Phase 1).
2026-06-25 03:42:57 +00:00
Dante
f19597ce81 feat(billing): deep link to open the pricing table (FE-1104) (#13001)
## Summary

Adds an in-app deep link that opens the pricing table directly, for
driving pilot users straight to subscribe (request from nav/Alex).
Resolves [FE-1104](https://linear.app/comfyorg/issue/FE-1104).

- `/?pricing=1` — on app load, open the pricing table.
- `/?pricing=team` / `/?pricing=personal` — open it on the Team /
Personal plan tab (via the existing `UnifiedPricingTable`
`initialPlanMode`).
- Gated to the **original owner** via
`useWorkspaceUI().permissions.canManageSubscriptionLifecycle` (personal
user, or a team workspace's original owner). A member or a promoted
owner is a **silent no-op**: the app loads normally, the param is
stripped, no 404 / error / toast.
- Off-cloud (OSS): the loader isn't instantiated, so the param is
ignored.
- Survives the login redirect via the preserved-query system, same as
`?invite` / `?create_workspace`.

## How

Mirrors the established URL-loader pattern (`useInviteUrlLoader` /
`useCreateWorkspaceUrlLoader`):

- `preservedQueryNamespaces.ts` / `router.ts` — register the `pricing`
namespace + tracker key.
- New `usePricingTableUrlLoader.ts` — hydrate preserved query, read
`pricing`, strip the param + `clearPreservedQuery` in a single replace
before any await, then `await fetchMembers()` (resolves the
original-owner gate; no-ops for personal) and open the table only when
the gate allows.
- `GraphCanvas.vue` — call the loader in `onMounted` after the
create-workspace loader (cloud only; not gated on the team-workspaces
flag so it also drives personal/legacy users).
- `useSubscriptionDialog.ts` — new `'deep_link'` value on
`SubscriptionDialogReason`.

## Telemetry

Eligible opens emit the existing `subscription_required_modal_opened`
PostHog event with the new `reason: 'deep_link'`. Ineligible-click
bounce rate is derivable from the autocaptured pageview URL
(`?pricing=…`), so no new event plumbing.

## Stacking / dependencies

This feature needs two sibling stacks off `main`:

- **FE-934** (`#12666`, base of this PR) — the `UnifiedPricingTable` +
`showPricingTable({ planMode })`.
- **FE-770** (`#12829`) — the `canManageSubscriptionLifecycle` gate.
**Merged into this branch**, so the diff against the FE-934 base
includes FE-770's changes until it lands. Review the single
`feat(billing): deep link…` commit. Once both land on `main`, rebase
onto `main` and the diff collapses to just this feature.

Do not merge before FE-770 and FE-934. Post-Billing-V1 follow-up.
End-state: swap the FE original-owner heuristic for the BE
workspace-level `is_original_owner` flag when it lands (removes the
members-fetch).

## Tests

- Unit (`usePricingTableUrlLoader.test.ts`, 12 cases): opens for an
original owner; `team`/`personal` tab preselect; silent no-op +
param-strip for a member/promoted owner; proves the gate is read only
after `fetchMembers` resolves; preserved-query restore;
empty/non-string/absent/unrecognized param; members-fetch failure
strips+clears without opening.
- E2E (`browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts`,
`@cloud`, 4 cases, verified locally): personal owner opens + URL
stripped; `?pricing=team` lands on the active Team tab; team original
owner opens (real `is_original_owner` + email gate); team member is a
silent no-op + URL stripped.
- Typecheck + related unit suites (`useSubscriptionDialog`,
`useWorkspaceUI`, `teamWorkspaceStore`) green.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-25 02:30:07 +00:00
Dante
988dc71955 feat(workspace): redesign Members invite UI to DES-186 (FE-768) (#12759)
## Summary

Redesigns the Settings ▸ Workspaces Members invite flow to DES-186:
email-chips invite dialog, Resend/Cancel pending-invite actions, and
explicit team-plan gating.

## Changes

- **What**:
- `InviteMemberDialogContent`: single-email link-generation flow →
comma/Enter/paste-separated email chips (`TagsInput`); batch
`createInvite` via `Promise.allSettled` — on partial failure, failed
emails stay as chips with an error toast; success shows an Invited
state.
- `PendingInvitesList`: inline Copy-link/Revoke buttons → `⋯` overflow
menu with **Resend invite** / **Cancel invite**. New
`teamWorkspaceStore.resendInvite` issues the fresh invite *before*
revoking the old one, so a failed resend never destroys the original.
- FE-built invite links removed
(`getInviteLink`/`createInviteLink`/`copyInviteLink`,
`PendingInvite.token`) — invite delivery is BE email per DES-186.
- New `useTeamPlan` composable: explicit `isOnTeamPlan` (seat-based
`maxSeats > 1` proxy until BE-1254 exposes the plan signal) replaces
`isSingleSeatPlan` branching in
`WorkspacePanelContent`/`useMembersPanel`.
- Personal/no-team-plan state per Figma 2993-14604: "To add teammates,
upgrade your plan." banner + **Upgrade to Team**; "Need more members?
Contact us" footer; upsell dialog copy → Team-plan framing; members sort
by Role column (was Join date).
- **Header layout per design (2nd commit)**: `[Search][Invite +][⋯
workspace menu]` moved from the dialog tab row into the members card
header row (the "N of M members" row) — the tab row right side is empty
in every DES-186 frame. Workspace menu extracted to
`WorkspaceMenuButton.vue`.
- **Single-member gating per design annotations**: when the owner is
alone — search hidden ("Show if more than 1 members"), Active/Pending
segmented tabs hidden, role column hidden, and the Members tab label
drops its count ("Remove '(0)' as well if it's just 1 member"). Tabs
reappear if pending invites exist so they stay manageable.
- **Breaking**: `teamWorkspaceStore` invite-link API removed (no
external consumers found in-repo).

## Review Focus

- Resend semantics (create-first, then revoke) and pending-invites state
update in `teamWorkspaceStore.resendInvite`.
- Invite-link removal assumes BE sends invite emails (DES-186 annotation
"Resends the invite email"). If BE email delivery is not yet live, this
PR should wait on that confirmation.
- Gating matrix: team-active owner → enabled; team at seat cap →
disabled+tooltip; non-team plan → upsell dialog; personal workspace →
disabled.
- Workspace `⋯` menu (Edit/Delete/Leave) now lives only on the Members
tab card header, matching DES-186 — the Plan & Credits tab no longer
exposes it (design shows the plan-card `⋯` there instead, shipped via
FE-964/FE-768b).

## Screenshots

Captured on `local.comfy.org` dev (cloud-prod backend, authenticated
session; team-plan states use client-side XHR-level API stubs for
subscription/members/invites since the test workspaces are
unsubscribed).

| Flow | Before (main) | After (this PR) |
|---|---|---|
| Members list — team plan | <img width="400" alt="before members"
src="https://github.com/user-attachments/assets/be214b95-4783-47ff-9539-59c8a33b5eb9"
/> | <img width="400" alt="after: Search/Invite/menu in the card header
row, Role column, design demo data"
src="https://github.com/user-attachments/assets/841c89d5-2a29-4eed-9c72-4a5ee8bee9f4"
/> |
| Pending invites — row actions | <img width="400" alt="before pending:
inline copy-link/revoke icons"
src="https://github.com/user-attachments/assets/1703850e-86bc-4735-81b3-7530c01ad46f"
/> | <img width="400" alt="after pending: overflow menu with
Resend/Cancel invite"
src="https://github.com/user-attachments/assets/9b1bbe03-d82b-4cf6-86f2-e01d7b898ae7"
/> |
| Team workspace with 1 member | (same chrome as members list: search +
tabs always shown) | <img width="400" alt="after: no search/tabs/role,
tab label without count, Invite + menu in card row"
src="https://github.com/user-attachments/assets/22c7f68a-eb0f-49a9-a203-516cd9b7e02d"
/> |
| Invite dialog | <img width="400" alt="before: single email, Create
link"
src="https://github.com/user-attachments/assets/c19d8bbb-feb5-4fe4-8541-6d52e6ab6600"
/> | <img width="400" alt="after: comma-separated email chips"
src="https://github.com/user-attachments/assets/101fdc7b-d6e0-4f7d-8966-894bbf16b4aa"
/> |
| Invite dialog — submit result | (copies a link) | <img width="400"
alt="after: Invited success state"
src="https://github.com/user-attachments/assets/f539a88c-0250-434c-bf4a-6ce714b30398"
/> |
| Upsell — invite without team plan | <img width="400" alt="before:
subscription required, Creator plan copy"
src="https://github.com/user-attachments/assets/bb2cb9fd-f298-4cb0-b39a-6d59061dcea1"
/> | <img width="400" alt="after: Team plan required, Upgrade to Team"
src="https://github.com/user-attachments/assets/45170ed5-63bd-469a-af12-197a8b7e09ee"
/> |
| Personal workspace — Members tab | (create-workspace hint text) | <img
width="400" alt="after: upgrade banner + disabled Invite"
src="https://github.com/user-attachments/assets/a0ae664b-2d20-4d87-900d-7a36872ecde3"
/> |

Linear:
[FE-768](https://linear.app/comfyorg/issue/FE-768/updates-to-workspaces-tab-of-settings)
(Members half; Plan & Credits panel ships separately stacked on FE-964)
2026-06-25 02:29:46 +00:00
Benjamin Lu
da55529d23 GTM-93 point Windows download at comfy.org proxy (#12974)
## Summary

- Point the website Windows desktop download URL at
`https://comfy.org/download/windows/nsis/x64`.
- Keep macOS on the existing ToDesktop URL.
- Update the download page smoke test to expect the new Windows href.

## Context

This is the frontend leg of the GTM-93 Windows MVP. ToDesktop still
controls `download.comfy.org`; instead of changing DNS, the website
sends Windows users to a controlled `comfy.org` proxy path that the
router PR handles. The proxy forwards to ToDesktop and adds a tokenized
`Content-Disposition` filename for Desktop to consume on Windows.

Linear:
https://linear.app/comfyorg/issue/GTM-93/fix-posthog-identify-call-unblock-funnel-attribution-desktop-funnel
Router PR: https://github.com/Comfy-Org/comfy-router/pull/33
Desktop PR: https://github.com/Comfy-Org/Comfy-Desktop/pull/1149

## Validation

- `pnpm --filter @comfyorg/website run typecheck`
- `pnpm --filter @comfyorg/website run build`
- `pnpm --filter @comfyorg/website exec playwright test
e2e/download.spec.ts`
- pre-commit: `pnpm typecheck`, `pnpm typecheck:website`
2026-06-24 17:29:53 -07:00
Dante
52d430d1b6 fix(billing): repoint direct-bypass billing consumers to the facade (B3) (FE-933) (#12643)
## What
**B3 — Repoint direct-bypass billing consumers to the facade.** Billing
data was read from the legacy `useSubscription` store / `authStore`
directly (empty or personal-only for team workspaces) instead of the
workspace-aware `useBillingContext` facade.

FE-933 (parent FE-903).

> **Stacked on #12622 (B2 / FE-904)** — depends on the facade `tier` /
`renewalDate` fields added there. Base is the B2 branch; retarget to
`main` once B2 merges.

## Repointed consumers
- **T3 — `SubscribeButton.vue`**: `subscribe_clicked` telemetry
`current_tier` ← facade `tier` (was wrong/empty for team users)
- **T4 — `PostHogTelemetryProvider.ts`**: PostHog `subscription_tier`
person property ← facade `tier` watch (tier-segmented analytics was
polluted for team users)
- **T5 — `FreeTierDialogContent.vue`**: next-refresh date ← facade raw
ISO `renewalDate`, formatted at the display site (the line silently
disappeared for team users)
- **`useSubscriptionActions.handleRefresh` + `SettingDialog`
credits-nav**: balance refresh ← facade `fetchBalance()` (was legacy
`/customers`-only `authActions.fetchBalance`)
- **`CurrentUserPopoverLegacy.vue`**: tier badge / balance / skeleton /
refreshes ← facade (`tier`, `balance`, `isLoading`, `fetchStatus`,
`fetchBalance`); tier name via shared `useWorkspaceTierLabel` instead of
a duplicated mapping
- **`PricingTable.vue`**: `isActiveSubscription` / `isFreeTier` / `tier`
/ yearly-vs-monthly ← facade; the billing-portal flow
(`accessBillingPortal` deep-links + proration) is intentionally
unchanged — facade `manageSubscription` is not behavior-identical

## Out of scope (triaged)
- `TopUpCreditsDialogContentLegacy` / `SubscriptionPanelContentLegacy` /
`useSubscriptionDialog` / cancellation watcher — legacy-mode-only
surfaces decommissioned by B1 (FE-966); repointing is churn, and
`useSubscriptionDialog` would create a legacy↔facade cycle
- `LegacyCreditsPanel` / `UserCredit` — deleted/orphaned by FE-964
(#12734); its successor `CreditsPanel.vue` keeps an
`authStore.lastBalanceUpdateTime` watch (no facade equivalent yet) —
follow-up after FE-964 lands

## Known semantic deltas (intentional, match shipped facade consumers)
- Balance-refresh failures no longer toast: legacy
`authActions.fetchBalance` wrapped errors with a toast; facade
`fetchBalance` rejections are void-ed, same as
`CurrentUserPopoverWorkspace` / `SubscriptionPanelContentWorkspace`.
Facade-level error surfacing is a follow-up.
- Popover skeleton keys on facade `isLoading` (init-time) rather than
per-fetch `isFetchingBalance`, matching the workspace popover.

## Tests
- New behavioral coverage: FreeTier renewal-date render/disappear,
popover tier badge + balance from facade, current-plan highlight from
facade tier+duration, facade-vs-legacy fetchBalance tripwire, PostHog
`subscription_tier` from facade tier.
- Local gates clean (typecheck / lint / format / dead-code); touched
unit files 71/71 pass.

## E2E coverage
Browser regression tests live in the stacked #12760
(`billingFacadeConsumers.spec.ts`, `@cloud`): avatar popover tier badge
+ balance, and the free-tier dialog renewal-date line (T5) rendered from
the facade. The team-user telemetry fixes (PostHog person property,
telemetry payload) are non-UI observables covered by unit tests that
mock only the facade and fail on revert.

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-25 00:09:14 +00:00
ShihChi Huang
7ab6cb57c5 test: 1/x fix coverage run (#13086)
## Summary

Fix the two current blockers that prevented `pnpm test:coverage` from
completing on `main`.

Stack order: 1/x

## Changes

- Mock `load3dAdvanced` in the lazy-loader test so coverage does not
import the real Load3DAdvanced UI graph.
- Track the active workflow status in `useWorkflowStatusDismissal` so
terminal statuses arriving after activation are cleared.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:coverage` |  failed, so the stack had no usable coverage
baseline |  passed with 877 test files passed; 11,772 passed / 8
skipped |
| focused tests | `load3dLazy` timed out; `useWorkflowStatusDismissal`
failed its active-workflow status case |  `load3dLazy`: 13 passed;
`useWorkflowStatusDismissal`: 4 passed |

## Coverage

| | before | after |
| -- | -- | -- |
| statements | unavailable | 62.84% |
| branches | unavailable | 53.03% |
| functions | unavailable | 56.94% |
| lines | unavailable | 64.05% |

Screenshots: N/A, no UI change.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a
summary for commit 94c4c9bac1. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-06-24 23:08:29 +00:00
Alexis Rolland
3c3a2ab4e2 fix: Load Audio node not caching execution (#12950)
## Summary

This PR fixes a bug where the Load Audio node re-executes everytime.

## Changes

- **What**: Mark `audioUIWidget.options.serialize = false`

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-24 23:04:26 +00:00
Dante
a07854755f fix(billing): restore unified pricing dialog width (Reka renderer regression) (#13092)
## Summary

Restore the unified "Choose a Plan" pricing dialog width — it was
collapsing to the default `md` (576px) frame, so the 1280px table
overflowed and rendered off-center with the right card clipped.

## Changes

- **What**: `showPricingTable` opens the unified dialog
(`SubscriptionRequiredDialogContentUnified`) with PrimeVue-path props
for sizing (`style: 'max-width: 95vw'` + `pt`). Since #12593 (FE-578
Phase 6a) made **Reka the default dialog renderer**, those props are
ignored — Reka sizes via `size`/`contentClass`, so the dialog fell back
to `size: 'md'` (`max-w-xl` = 576px). The content root's
`xl:w-[min(1280px,95vw)]` then overflowed the 576px box and shifted
off-center. Moved the width onto a Reka `contentClass` (`w-fit
max-w-[min(1280px,95vw)]`), matching the sibling subscription dialogs in
the same file.

## Review Focus

- **Regression origin**: the broken config landed when #12666 (FE-934,
UnifiedPricingTable) merged on top of #12593's reka-default flip while
still using the PrimeVue config. No merge conflict — the `style` line is
valid but dead, so it broke silently. FE-991 (#12792) predates #12593,
so it still rendered via PrimeVue and looked correct (matching the
report that it was fine there).
- **`w-fit` vs fixed width**: `w-fit` preserves the original "dialog
hugs its content per step" intent — the content root only sets the
1280px width on the pricing step, so confirm/success steps still shrink
instead of floating in a 1280px box.
- Out of scope: the legacy-team / flag-off paths share a PrimeVue
`style` shell and are likely affected the same way under Reka; left for
a follow-up (flag-off is the lower-priority OSS path).

## Verification

- Unit test `useSubscriptionDialog.test.ts` — red without the fix
(dialog has no `contentClass`), green with it.
- Verified live (cloud dev, viewport 1301px): box centered at 1236px
(95vw), no overflow, all three personal cards visible.

## Screenshots

Personal tab, viewport 1301px:

| Before | After |
| --- | --- |
| <img width="480" alt="before"
src="https://github.com/user-attachments/assets/e233fe00-f754-4e34-837f-cf6630ccbfb9"
/> | <img width="480" alt="after"
src="https://github.com/user-attachments/assets/dedd92b7-8707-4865-b7f3-289919043b48"
/> |
2026-06-24 22:23:00 +00:00
Talmaj Marinc
842e3d7541 Initial commit for DynamiGroupSupport. 2026-06-25 00:14:28 +02:00
CodeJuggernaut
2adef5d9f6 Create script for pointing at prod and staging backends (#13096)
## Summary

Allows engineers to run their localhost frontend while choosing which
backend to point. This PR adds staging and prod as targets.
## Changes

- **What**: New NPM scripts: `dev:cloud:test`, `dev:cloud:staging`, and
`dev:cloud:prod`. `dev:cloud` points at `dev:cloud:test`
- **Breaking**: None

## Why

Currently, the testcloud environment is broken (backend config issue)
and doesn't allow going through the subscription registration process.
This also allows testing frontend code against backend changes being
staged for release, as well as against actual backend production code.
2026-06-24 21:39:42 +00:00
248 changed files with 20344 additions and 3776 deletions

View File

@@ -2,6 +2,7 @@ issue_enrichment:
auto_enrich:
enabled: true
reviews:
profile: assertive
high_level_summary: false
request_changes_workflow: true
auto_review:

View File

@@ -133,3 +133,24 @@ jobs:
exit 1
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
-e '1x00000000000000000000AA' \
dist; then
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
exit 1
fi
echo '✅ No Turnstile sitekey references found'

View File

@@ -41,7 +41,7 @@ jobs:
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
# Custom PR comment messages
custom-notsigned-prcomment: |

View File

@@ -83,6 +83,16 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
},
{
find: '@/composables/useFeatureFlags',
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -47,6 +47,11 @@ test.describe('Download page @smoke', () => {
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
await expect(downloadBtn).toHaveAttribute(
'href',
'https://comfy.org/download/windows/nsis/x64'
)
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
@@ -73,7 +78,7 @@ test.describe('Download page @smoke', () => {
})
const windowsBtn = hero.locator(
'a[href="https://download.comfy.org/windows/nsis/x64"]'
'a[href="https://comfy.org/download/windows/nsis/x64"]'
)
await expect(windowsBtn).toBeVisible()
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)

View File

@@ -72,6 +72,7 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { externalLinks } from '@/config/routes'
export const downloadUrls = {
windows: 'https://download.comfy.org/windows/nsis/x64',
windows: 'https://comfy.org/download/windows/nsis/x64',
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const

View File

@@ -0,0 +1,96 @@
import type {
BillingStatusResponse,
Member,
Plan,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
// `/api/features` is the remote-config source: production builds resolve the
// workspaces flag from it (the `ff:` localStorage override is dev-only).
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
team_workspaces_enabled: true
}
export const TEAM_WORKSPACE: WorkspaceWithRole = {
id: 'ws-team',
name: 'Team Comfy',
type: 'team',
created_at: '2025-01-01T00:00:00Z',
joined_at: '2025-01-02T00:00:00Z',
role: 'owner',
subscription_tier: 'PRO'
}
export const CREATOR: Member = {
id: 'u-liz',
name: 'Liz',
email: 'liz@test.comfy.org',
joined_at: '2025-01-01T00:00:00Z',
role: 'owner',
is_original_owner: true
}
// Identity must match the CloudAuthHelper mock user so this row counts as
// "(You)".
export const VIEWER: Member = {
id: 'u-me',
name: 'E2E Test User',
email: 'e2e@test.comfy.org',
joined_at: '2025-01-02T00:00:00Z',
role: 'owner',
is_original_owner: false
}
export const MEMBER_JANE: Member = {
id: 'u-jane',
name: 'Jane',
email: 'jane@test.comfy.org',
joined_at: '2025-01-03T00:00:00Z',
role: 'member',
is_original_owner: false
}
export const MEMBER_JOHN: Member = {
id: 'u-john',
name: 'John',
email: 'john@test.comfy.org',
joined_at: '2025-01-04T00:00:00Z',
role: 'member',
is_original_owner: false
}
export const DEFAULT_TEAM_MEMBERS: Member[] = [
CREATOR,
VIEWER,
MEMBER_JANE,
MEMBER_JOHN
]
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
is_active: true,
subscription_status: 'active',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
plan_slug: 'pro-monthly',
billing_status: 'paid',
has_funds: true,
renewal_date: '2099-02-20T00:00:00Z'
}
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
// the whole role-management UI.
export const TEAM_PRO_PLAN: Plan = {
slug: 'pro-monthly',
tier: 'PRO',
duration: 'MONTHLY',
price_cents: 10000,
credits_cents: 21100,
max_seats: 30,
availability: { available: true },
seat_summary: {
seat_count: 4,
total_cost_cents: 40000,
total_credits_cents: 0
}
}

View File

@@ -0,0 +1,150 @@
import type { Page, Route } from '@playwright/test'
import type { Member } from '@/platform/workspace/api/workspaceApi'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import {
DEFAULT_TEAM_MEMBERS,
TEAM_BILLING_STATUS,
TEAM_PRO_PLAN,
TEAM_WORKSPACE,
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
interface RoleChangeRequest {
url: string
role: string
}
interface MemberMockState {
members: Member[]
patches: RoleChangeRequest[]
}
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
/**
* Boots the cloud app against fully mocked workspace + billing endpoints so
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
* to reach the OSS devtools backend during setup).
*
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
* `patches` records every role-change request for assertion.
*/
export class CloudWorkspaceMockHelper {
constructor(private readonly page: Page) {}
async setup(
members: Member[] = DEFAULT_TEAM_MEMBERS
): Promise<MemberMockState> {
const state = await this.mockBoot(members)
await new CloudAuthHelper(this.page).mockAuth()
await this.page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
})
return state
}
private async mockBoot(members: Member[]): Promise<MemberMockState> {
const state: MemberMockState = {
members: members.map((m) => ({ ...m })),
patches: []
}
const { page } = this
await page.route('**/api/features', (r) =>
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// A non-empty settings payload with TutorialCompleted marks the user as
// returning, so the new-user Templates dialog never auto-opens to block the
// Settings button. Errors tab off suppresses the model-folder 401 toast.
await page.route('**/api/settings', (r) =>
r.fulfill(
jsonRoute({
'Comfy.TutorialCompleted': true,
'Comfy.RightSidePanel.ShowErrorsTab': false
})
)
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
)
await page.route('**/api/workspace/members**', (route: Route) => {
const request = route.request()
if (request.method() === 'PATCH') {
const url = request.url()
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
const { role } = request.postDataJSON() as { role: Member['role'] }
state.patches.push({ url, role })
const member = state.members.find((m) => m.id === id)
if (member) member.role = role
// Echo the updated row like the real BE; the store merges only the role
// locally, so the response body shape is not load-bearing.
return route.fulfill(jsonRoute(member))
}
return route.fulfill(
jsonRoute({
members: state.members,
pagination: { offset: 0, limit: 50, total: state.members.length }
})
)
})
await page.route('**/api/workspace/invites', (r) =>
r.fulfill(jsonRoute({ invites: [] }))
)
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(
jsonRoute({
amount_micros: 6000,
currency: 'usd',
effective_balance_micros: 6000,
cloud_credit_balance_micros: 5000,
prepaid_balance_micros: 1000
})
)
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
)
)
return state
}
}

View File

@@ -235,9 +235,6 @@ export const TestIds = {
renameInput: 'subgraph-breadcrumb-rename-input',
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
},
workflowActions: {
viewModeToggle: 'view-mode-toggle'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`

View File

@@ -0,0 +1,34 @@
import type { Page } from '@playwright/test'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
/**
* Minimal valid billing shapes so the billing facade resolves while a
* subscription dialog mounts. Active personal sub with zero balance.
*/
export async function mockBilling(page: Page) {
await page.route('**/api/billing/status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
has_funds: true,
subscription_status: 'active',
subscription_tier: 'pro',
subscription_duration: 'MONTHLY',
billing_status: 'paid'
})
)
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute({ is_active: false }))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
)
}

View File

@@ -0,0 +1,64 @@
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
interface CloudBootOptions {
/** Remote-config payload for `/api/features` (enables the flags under test). */
features: RemoteConfig
/** Body for `/api/settings` (defaults to `{}`). */
settings?: unknown
}
/**
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
* working app without falling through to the OSS devtools backend. Specs layer
* their own feature- or flow-specific routes on top.
*/
export async function mockCloudBoot(
page: Page,
{ features, settings = {} }: CloudBootOptions
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
/**
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
* original-owner gate matches against the members self-row.
*/
export async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}

View File

@@ -0,0 +1,12 @@
/**
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
* drift against the real API shape.
*/
export function jsonRoute<T>(body: T) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}

View File

@@ -0,0 +1,68 @@
import type { Page } from '@playwright/test'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
export function workspace(
type: 'personal' | 'team',
role: 'owner' | 'member'
): WorkspaceWithRole {
return {
id: `ws-${type}`,
name: type === 'team' ? 'My Team' : 'Personal Workspace',
type,
role,
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z'
}
}
export function member(
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
): Member {
return {
id: `user-${overrides.email}`,
name: overrides.email,
joined_at: '2026-01-01T00:00:00Z',
is_original_owner: false,
...overrides
}
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
*/
export async function mockWorkspace(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({
members,
pagination: { offset: 0, limit: 50, total: members.length }
})
)
)
}

View File

@@ -137,122 +137,6 @@ test.describe('App mode usage', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test('Shares the graph side toolbar, filtered to assets + apps', async ({
comfyPage
}) => {
const sideToolbar = comfyPage.page.getByTestId(TestIds.sidebar.toolbar)
await test.step('Graph mode shows the full toolbar', async () => {
await expect(sideToolbar).toBeVisible()
await expect(
sideToolbar.locator('.node-library-tab-button')
).toBeVisible()
})
await test.step('App mode reuses it with only assets + apps', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(sideToolbar).toBeVisible()
await expect(sideToolbar.locator('.assets-tab-button')).toBeVisible()
await expect(sideToolbar.locator('.apps-tab-button')).toBeVisible()
await expect(sideToolbar.locator('.node-library-tab-button')).toBeHidden()
})
})
test('Workflow actions menu keeps the same position across graph/app mode', async ({
comfyPage
}) => {
// Toggling graph<->app mode happens from this control, so it must not move
// out from under the cursor as the mode flips.
const graphActions = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.getByRole('button', { name: 'Workflow actions' })
await expect(graphActions).toBeVisible()
const graphBox = await graphActions.boundingBox()
expect(graphBox).not.toBeNull()
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
const appActions = comfyPage.page
.getByTestId(TestIds.linear.centerPanel)
.getByRole('button', { name: 'Workflow actions' })
await expect(appActions).toBeVisible()
// The toggle segments reorder (morph) as the mode flips, so poll until the
// active control settles at the same x it occupied in graph mode.
await expect
.poll(async () => {
const box = await appActions.boundingBox()
return box ? Math.abs(box.x - graphBox!.x) : Infinity
})
.toBeLessThanOrEqual(1)
})
test('Toggle segment flips mode without opening the menu', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await expect(toggle).toBeVisible()
await comfyPage.page.getByRole('button', { name: 'Enter app mode' }).click()
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The inactive segment switches mode; it must not also open the actions menu.
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
await expect(toggle).toBeVisible()
})
test('Toggle segment flips mode via keyboard without opening the menu', async ({
comfyPage
}) => {
const appSegment = comfyPage.page.getByRole('button', {
name: 'Enter app mode'
})
await appSegment.focus()
await appSegment.press('Enter')
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// Keyboard activation of the inactive segment must switch mode without the
// keydown bubbling to the trigger and opening the actions menu.
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
})
test('Mode toggle returns to app mode after exiting the builder', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await comfyPage.appMode.enableLinearMode()
await expect(toggle).toBeVisible()
await comfyPage.appMode.enterBuilder()
await expect(toggle).toBeHidden()
await comfyPage.appMode.footer.exitButton.click()
await expect(toggle).toBeVisible()
})
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(toggle).toBeVisible()
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
await comfyPage.menu.assetsTab.tabButton.click()
await expect(toggle).toBeVisible()
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

View File

@@ -0,0 +1,194 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
BillingBalanceResponse,
BillingStatusResponse
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
/**
* Billing facade consumers — FE-933 (B3) regression.
*
* The repointed surfaces (avatar popover tier badge / balance, free-tier
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
* selects its backend by flag: `team_workspaces_enabled: false` routes through
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
// The workspace `/api/billing/status` shape mirrors the legacy subscription
// status; map the fields so a single test fixture drives both backends.
const toWorkspaceStatus = (
s: CloudSubscriptionStatusResponse
): BillingStatusResponse => ({
is_active: s.is_active ?? false,
subscription_tier: s.subscription_tier ?? undefined,
subscription_duration: s.subscription_duration ?? undefined,
renewal_date: s.renewal_date ?? undefined,
cancel_at: s.end_date ?? undefined,
has_funds: s.has_fund ?? true
})
const mockBalance: BillingBalanceResponse = {
amount_micros: 6000, // -> 12,660 credits
currency: 'usd',
effective_balance_micros: 6000
}
async function mockCloudBoot(
page: Page,
subscriptionStatus: CloudSubscriptionStatusResponse,
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
) {
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// TutorialCompleted suppresses the new-user template browser, whose modal
// overlay would otherwise intercept clicks on the topbar.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
// Legacy backend (team_workspaces_enabled: false).
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(jsonRoute(subscriptionStatus))
)
await page.route('**/customers/balance', (r) =>
r.fulfill(jsonRoute(mockBalance))
)
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
// now routes through `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(jsonRoute(mockBalance))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
}
async function bootApp(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
}
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
test('avatar popover renders tier badge and balance from the facade', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page, {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
})
await bootApp(page)
await page.getByRole('button', { name: 'Current user' }).click()
const popover = page.locator('.current-user-popover')
await expect(popover).toBeVisible()
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
await expect(popover.getByText('12,660')).toBeVisible()
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
})
test('free-tier dialog shows the renewal date from the facade', async ({
page
}) => {
test.setTimeout(60_000)
// Boots with team workspaces enabled (production shape); the facade routes a
// personal workspace through the workspace `/api/billing/*` endpoints. With
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
// overwrites window.__CONFIG__ from /api/features, so the flags must come
// from the features mock, not an init script.)
await mockCloudBoot(
page,
{
is_active: false,
subscription_tier: 'FREE',
subscription_duration: 'MONTHLY',
// 10:00Z keeps the en-US calendar date stable across CI timezones.
renewal_date: '2099-02-20T10:00:00Z',
end_date: null
},
{ team_workspaces_enabled: true, subscription_required: true }
)
await bootApp(page)
await page.getByTestId('subscribe-to-run-button').click()
// T5: the dialog must source the date from facade renewalDate — when this
// line read the legacy store it silently vanished for team users.
await expect(
page.getByText('Your credits refresh on Feb 20, 2099.')
).toBeVisible()
})
})

View File

@@ -4,8 +4,7 @@ import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
/**
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
@@ -16,51 +15,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
function jsonRoute(body: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
}
}
async function mockCloudBoot(page: Page) {
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Cloud user status (getUserCloudStatus) — an active account so the gate
// proceeds to the survey check instead of bouncing back to login.
await page.route('**/api/user', (r) =>
r.fulfill(jsonRoute({ status: 'active' }))
)
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
}
// `/api/features` is the remote-config source: production builds resolve
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
// dev-only). Enable the survey so the gate is actually live.
const BOOT_FEATURES = {
onboarding_survey_enabled: true
} satisfies RemoteConfig
// Genuine "not completed": the cloud backend returns 404 for a survey key that
// was never stored. This is the response that must still route to the survey.
@@ -89,22 +49,13 @@ async function mockSurveyTransient401(page: Page) {
)
}
async function bootCloud(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
}
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
page
}) => {
test.setTimeout(60_000)
test.slow()
await mockCloudBoot(page)
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockSurveyTransient401(page)
await bootCloud(page)
@@ -122,9 +73,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
test('a not-completed (404) user landing on / is routed to the survey', async ({
page
}) => {
test.setTimeout(60_000)
test.slow()
await mockCloudBoot(page)
await mockCloudBoot(page, { features: BOOT_FEATURES })
await mockSurveyNotCompleted(page)
await bootCloud(page)

View File

@@ -2,7 +2,10 @@ import { expect } from '@playwright/test'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import type { operations } from '@/types/comfyRegistryTypes'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
@@ -51,6 +54,20 @@ const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
end_date: FUTURE_DATE
}
// With team workspaces enabled, the facade routes a personal workspace through
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
// visible (free tier would swap it for "Upgrade to add credits").
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_status: 'canceled',
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
has_funds: true,
cancel_at: FUTURE_DATE,
renewal_date: FUTURE_DATE
}
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
// of the popover before the fix.
const mockBalance: CustomerBalanceResponse = {
@@ -105,6 +122,32 @@ const test = comfyPageFixture.extend({
})
)
// Flag-on (team workspaces enabled) routes a personal workspace through the
// workspace billing endpoints, so the popover sources its data from here.
await page.route('**/api/billing/status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBillingStatus)
})
)
await page.route('**/api/billing/balance', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockBalance)
})
)
await page.route('**/api/billing/plans', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ plans: [] })
})
)
await use(page)
}
})

View File

@@ -0,0 +1,264 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
// devtools backend during setup.
/**
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
*
* The credits tile only lives inside the authenticated cloud app, which the
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
* the cloud app initializes against fully stubbed data. With team workspaces
* enabled the facade routes a personal workspace through the workspace
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
* should then render its total / progress bar / monthly+additional breakdown /
* add-credits.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
const jsonRoute = (body: unknown) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
// same response shape, so one body fulfills both endpoints.
const balanceRoute = (balance: {
amount: number
monthly: number
prepaid: number
}) =>
jsonRoute({
amount_micros: balance.amount,
currency: 'usd',
effective_balance_micros: balance.amount,
cloud_credit_balance_micros: balance.monthly,
prepaid_balance_micros: balance.prepaid
})
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
const mockBillingStatus: BillingStatusResponse = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T12:00:00Z',
has_funds: true
}
async function mockCloudBoot(page: Page) {
// Frontend-origin boot endpoints (proxied to the backend in production).
// `/api/features` is the remote-config source: production builds resolve
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
// dev-only), and the flag gates the Workspace settings panel.
await page.route('**/api/features', (r) =>
r.fulfill(
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
)
)
await page.route('**/api/system_stats', (r) =>
r.fulfill(jsonRoute(mockSystemStats))
)
// Include the mock user so the multi-user select screen auto-selects it
// (paired with the `Comfy.userId` localStorage seed below).
await page.route('**/api/users', (r) =>
r.fulfill(
jsonRoute({
storage: 'server',
migrated: true,
users: { 'test-user-e2e': 'E2E Test User' }
})
)
)
// Non-empty settings with a completed tutorial keep the cloud app from
// booting as a new user, whose Workflow Templates dialog would otherwise
// auto-open and intercept the Settings click behind its modal backdrop.
await page.route('**/api/settings', (r) =>
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
)
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.
await page.route('**/api/workspaces', (r) =>
r.fulfill(
jsonRoute({
workspaces: [
{
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}
]
})
)
)
// Legacy billing (flag-off path, api.comfy.org/customers/*).
await page.route('**/customers/cloud-subscription-status', (r) =>
r.fulfill(
jsonRoute({
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY',
renewal_date: '2099-02-20T12:00:00Z',
end_date: null
})
)
)
await page.route('**/customers/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
)
// Workspace billing (flag-on path) — a personal workspace now routes through
// `/api/billing/*`.
await page.route('**/api/billing/status', (r) =>
r.fulfill(jsonRoute(mockBillingStatus))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(DEFAULT_BALANCE))
)
await page.route('**/api/billing/plans', (r) =>
r.fulfill(jsonRoute({ plans: [] }))
)
}
async function mockBalance(
page: Page,
balance: { amount: number; monthly: number; prepaid: number }
) {
await page.unroute('**/customers/balance')
await page.unroute('**/api/billing/balance')
await page.route('**/customers/balance', (r) =>
r.fulfill(balanceRoute(balance))
)
await page.route('**/api/billing/balance', (r) =>
r.fulfill(balanceRoute(balance))
)
}
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
async function openPlanAndCredits(page: Page) {
const auth = new CloudAuthHelper(page)
await auth.mockAuth()
// Pre-select the mock user to skip the user-select screen.
await page.addInitScript(() => {
localStorage.setItem('Comfy.userId', 'test-user-e2e')
})
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
// Open Settings ▸ Workspace.
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
return dialog.getByRole('main')
}
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
test('renders the unified tile with breakdown and add-credits', async ({
page
}) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
const content = await openPlanAndCredits(page)
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
// 10,550 -> used 10,550).
await expect(content.getByText('Total credits')).toBeVisible()
await expect(content.getByText('12,660')).toBeVisible()
// Monthly usage bar header + used / left-of-total labels.
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
await expect(content.getByText(/Refills Feb/)).toBeVisible()
await expect(content.getByText('10,550 used')).toBeVisible()
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
// Additional credits row + subtitle.
await expect(content.getByText('Additional credits')).toBeVisible()
await expect(content.getByText('2,110')).toBeVisible()
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
// Permission-gated add-credits action (personal owner can top up).
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
// Narrow container (DES-247 responsive variants): drop the used/remaining
// labels and the breakdown subtitle, compact the monthly summary numbers.
await page.setViewportSize({ width: 360, height: 800 })
await expect(content.getByText('10,550 used')).toBeHidden()
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
await expect(content.getByText('11K left of 21K')).toBeVisible()
})
test('renders the depleted-credit empty states', async ({ page }) => {
test.setTimeout(60_000)
await mockCloudBoot(page)
// Monthly allowance fully spent; additional credits keep generation going.
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
const content = await openPlanAndCredits(page)
// 0-monthly state: depletion notice + IN USE badge on additional credits.
await expect(
content.getByText('Monthly credits are used up. Refills Feb 20')
).toBeVisible()
await expect(
content.getByText("You're now spending additional credits.")
).toBeVisible()
await expect(content.getByText('In use')).toBeVisible()
await expect(content.getByText('0 left of 21,100')).toBeVisible()
// Drain the remaining additional credits and refresh the tile: the
// out-of-credits notice takes over and the badge drops.
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
await content.getByRole('button', { name: 'Refresh credits' }).click()
await expect(
content.getByText("You're out of credits. Credits refill Feb 20")
).toBeVisible()
await expect(
content.getByText('Add more credits to continue generating.')
).toBeVisible()
await expect(content.getByText('In use')).toBeHidden()
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
})
})

View File

@@ -0,0 +1,264 @@
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { Member } from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import {
CREATOR,
MEMBER_JANE,
MEMBER_JOHN,
VIEWER
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
// devtools backend during setup.
/**
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
*
* The viewer is a promoted owner (not the workspace creator), so the spec can
* distinguish the creator guard from the self guard: the creator row and the
* viewer's own row hide the row menu, every other row exposes
* "Change role " (Owner / Member) plus "Remove member". Promoting a member
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
* re-sorts the row under the creator, and the promoted owner stays demotable.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
async function openMembersTab(page: Page): Promise<Locator> {
await page.goto(APP_URL)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await page
.getByRole('button', { name: /^Settings/ })
.first()
.click()
const dialog = page.getByTestId('settings-dialog')
await expect(dialog).toBeVisible()
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
const content = dialog.getByRole('main')
await content.getByRole('tab', { name: /Members/ }).click()
await expect(content.getByText('4 of 30 members')).toBeVisible()
return content
}
function memberRow(content: Locator, email: string): Locator {
return content
.locator('div.grid')
.filter({ has: content.page().getByText(email, { exact: true }) })
}
function menuButton(row: Locator): Locator {
return row.getByRole('button', { name: 'More Options' })
}
// Reka submenus open on real pointer travel or keyboard; Playwright's
// synthetic hover doesn't trigger the pointermove handler, so drive the
// subtrigger with ArrowRight instead.
async function openChangeRoleSubmenu(page: Page) {
const trigger = page.getByRole('menuitem', { name: 'Change role' })
await expect(trigger).toBeVisible()
await trigger.press('ArrowRight')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
).toBeVisible()
}
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })
test('row menus respect creator and self guards', async ({ page }) => {
await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
// own row; the two plain members each expose a menu.
await expect(
menuButton(memberRow(content, MEMBER_JOHN.email))
).toBeVisible()
await expect(
menuButton(memberRow(content, MEMBER_JANE.email))
).toBeVisible()
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
await expect(
page.getByRole('menuitem', { name: 'Change role' })
).toBeVisible()
await page.getByRole('menuitem', { name: 'Remove member' }).click()
await expect(page.getByText('Remove this member?')).toBeVisible()
})
test('selecting the current role is a no-op', async ({ page }) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
// The current role is a checked radio item so assistive tech can announce
// which role is active.
await expect(
page.getByRole('menuitemradio', { name: 'Member', exact: true })
).toHaveAttribute('aria-checked', 'true')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
).toHaveAttribute('aria-checked', 'false')
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
0
)
expect(state.patches).toHaveLength(0)
})
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
page
}) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await expect(page.getByText("They'll be able to:")).toBeVisible()
await expect(page.getByText('Add additional credits')).toBeVisible()
await expect(
page.getByText('Manage members, payment methods, and workspace settings')
).toBeVisible()
await expect(
page.getByText(
'Promote and demote other owners (except the workspace creator).'
)
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toHaveCount(0)
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
expect(state.patches).toHaveLength(0)
})
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
page
}) => {
const state = await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)
const emails = content.getByText(/@test\.comfy\.org/)
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
MEMBER_JOHN.email,
MEMBER_JANE.email
])
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
await expect(page.getByText('Role updated')).toBeVisible()
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
MEMBER_JANE.email,
MEMBER_JOHN.email
])
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'owner'
}
])
// The promoted owner keeps its row menu (still demotable).
await expect(menuButton(janeRow)).toBeVisible()
})
test('demoting an owner returns them to member', async ({ page }) => {
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
const state = await new CloudWorkspaceMockHelper(page).setup([
CREATOR,
VIEWER,
ownerJane,
MEMBER_JOHN
])
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
await expect(
page.getByRole('heading', { name: 'Demote Jane to member?' })
).toBeVisible()
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
await page.getByRole('button', { name: 'Demote to member' }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
expect(state.patches).toEqual([
{
url: expect.stringContaining('/api/workspace/members/u-jane'),
role: 'member'
}
])
})
test('failed role change keeps the dialog open with an error toast', async ({
page
}) => {
await new CloudWorkspaceMockHelper(page).setup()
// Override the member route so PATCH fails after boot succeeds.
await page.route('**/api/workspace/members/**', (route) =>
route.request().method() === 'PATCH'
? route.fulfill({ status: 500, body: '{}' })
: route.fallback()
)
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
// US10 — error toast, dialog stays open, role unchanged.
await expect(page.getByText('Failed to update role')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
})
})

View File

@@ -0,0 +1,128 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
Member,
WorkspaceWithRole
} from '@/platform/workspace/api/workspaceApi'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import {
member,
mockWorkspace,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* The `?pricing=` deep link opens the pricing table on app load, gated to the
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the
// GraphCanvas onMounted chain before the deep-link loader.
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
// chain must not throw before it: a missing settings subpath, prompt exec_info,
// or queue status each abort that chain.
async function mockGraphBootExtras(page: Page) {
// Boot only reads these; fall back on any write so an unexpected POST/PUT
// surfaces instead of being masked by a blanket 200.
await page.route('**/api/settings/**', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({}))
})
await page.route('**/api/prompt', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
})
await page.route('**/api/queue', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
})
}
async function setupCloudApp(
page: Page,
ws: WorkspaceWithRole,
members: Member[]
) {
await mockCloudBoot(page, {
features: BOOT_FEATURES,
settings: BOOT_SETTINGS
})
await mockGraphBootExtras(page)
await mockBilling(page)
await mockWorkspace(page, ws, members)
await bootCloud(page)
}
const pricingHeading = (page: Page) =>
page.getByRole('heading', { name: 'Choose a Plan' })
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
test('opens the pricing table for a personal owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(page).not.toHaveURL(/[?&]pricing=/)
})
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('personal', 'owner'), [])
await page.goto(`${APP_URL}/?pricing=team`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
await expect(
page.getByRole('button', { name: 'For Teams' })
).toHaveAttribute('aria-pressed', 'true')
})
test('opens for a team original owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
])
await page.goto(`${APP_URL}/?pricing=1`)
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
})
test('is a silent no-op for a team member', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'member'), [
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'member' })
])
await page.goto(`${APP_URL}/?pricing=1`)
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
timeout: 45_000
})
await expect(page).not.toHaveURL(/[?&]pricing=/)
await expect(pricingHeading(page)).toBeHidden()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -6,6 +6,7 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const getHeaderPos = async (
@@ -359,6 +360,55 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
})
test('dragging a node moves all selected items', async ({
comfyPage,
comfyMouse
}) => {
const samplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
const ksampler = new VueNodeFixture(samplerLocator)
const loaderLocator = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const loader = new VueNodeFixture(loaderLocator)
await test.step('create graph with group and reroute', async () => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Checkpoint')
const samplerOptions = { position: { x: 800, y: 200 } }
await comfyPage.searchBoxV2.addNode('KSampler', samplerOptions)
await ksampler.getSlot('model').dragTo(loader.getSlot('MODEL'))
await test.step('add reroute', async () => {
const b1 = await ksampler.getSlot('model').boundingBox()
const b2 = await loader.getSlot('MODEL').boundingBox()
if (!b1 || !b2) throw new Error('Failed to get bounds')
const x = (b1.x + b2.x + (b1.width + b2.width) / 2) / 2
const y = (b1.y + b2.y + (b1.height + b2.height) / 2) / 2
await comfyPage.page.keyboard.down('Alt')
await comfyPage.page.mouse.click(x, y)
await comfyPage.page.keyboard.up('Alt')
const rerouteCount = () =>
comfyPage.page.evaluate(() => graph!.reroutes.size)
await expect.poll(rerouteCount).toBe(1)
})
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('Control+G')
await comfyPage.keyboard.selectAll()
})
const getReroutePos = () =>
comfyPage.page.evaluate(() => [...graph!.reroutes.values()][0])
const getGroupPos = () =>
comfyPage.page.evaluate(() => graph!.groups[0].pos)
const initialReroutePos = await getReroutePos()
const initialGroupPos = await getGroupPos()
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
await expect.poll(getReroutePos).not.toEqual(initialReroutePos)
await expect.poll(getGroupPos).not.toEqual(initialGroupPos)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -0,0 +1,26 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('tooltips', { tag: '@vue-nodes' }, async () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.settings.setSetting('LiteGraph.Node.TooltipDelay', 0)
})
test('widget value tooltips', async ({ comfyPage }) => {
const tooltip = comfyPage.page.locator('.p-tooltip-text')
await comfyPage.vueNodes.getWidgetByName('load check', 'ckpt_name').hover()
await expect(tooltip, 'displays for combos').toContainText('v1-5-pruned')
await comfyPage.vueNodes.getWidgetByName('ksampler', 'seed').hover()
await expect(tooltip, 'displays for numbers').toContainText('15668')
await comfyPage.vueNodes.getNodeLocator('6').getByLabel('text').hover()
await expect(tooltip).toBeVisible()
await expect(tooltip, "doesn't display for prompts").not.toContainText(
'purple galaxy bottle'
)
})
})

View File

@@ -73,4 +73,16 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
await expect(widget, 'Widget has restored value').toHaveText('scale width')
})
test('Dynamic children have separate state', async ({ comfyPage }) => {
const nodeName = 'Node With Dynamic Combo'
await comfyPage.searchBoxV2.addNode(nodeName, {
position: { x: 200, y: 150 }
})
const child = comfyPage.vueNodes.getWidgetByName(nodeName, 'suboption')
await expect(child, 'initial state').toHaveText('1x')
await comfyPage.vueNodes.selectComboOption(nodeName, 'combo', 'option2')
await expect(child, 'child of same name has new state').toHaveText('2x')
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.47.3",
"version": "1.47.5",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -19,7 +19,10 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
"dev:cloud": "pnpm dev:cloud:test",
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",

View File

@@ -52,6 +52,8 @@
--color-gold-500: #fdab34;
--color-gold-600: #fd9903;
--color-credit: #fabc25;
--color-coral-500: #f75951;
--color-coral-600: #e04e48;
--color-coral-700: #b33a3a;
@@ -236,6 +238,8 @@
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--tertiary-background: var(--color-smoke-400);
--tertiary-background-hover: var(--color-smoke-500);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
--primary-background-hover: var(--color-cobalt-800);
@@ -384,6 +388,8 @@
--secondary-background: var(--color-charcoal-600);
--secondary-background-hover: var(--color-charcoal-400);
--secondary-background-selected: var(--color-charcoal-200);
--tertiary-background: var(--color-charcoal-400);
--tertiary-background-hover: var(--color-charcoal-300);
--base-background: var(--color-charcoal-800);
--primary-background: var(--color-azure-600);
--primary-background-hover: var(--color-azure-400);
@@ -554,6 +560,8 @@
--color-secondary-background: var(--secondary-background);
--color-secondary-background-hover: var(--secondary-background-hover);
--color-secondary-background-selected: var(--secondary-background-selected);
--color-tertiary-background: var(--tertiary-background);
--color-tertiary-background-hover: var(--tertiary-background-hover);
--color-primary-background: var(--primary-background);
--color-primary-background-hover: var(--primary-background-hover);
--color-destructive-background: var(--destructive-background);

View File

@@ -344,6 +344,15 @@ export const zDynamicComboInputSpec = z.tuple([
})
])
export const zDynamicGroupInputSpec = z.tuple([
z.literal('COMFY_DYNAMICGROUP_V3'),
zBaseInputOptions.extend({
template: zComfyInputsSpec,
min: z.number().int().nonnegative().optional().default(0),
max: z.number().int().positive().max(100).optional().default(50)
})
])
export const zMatchTypeOptions = z.object({
...zBaseInputOptions.shape,
type: z.literal('COMFY_MATCHTYPE_V3'),

View File

@@ -1,33 +1,119 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { isCloud } from '@/platform/distribution/types'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { hasNodes } = storeToRefs(appModeStore)
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
)
const isAppsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
)
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
}
</script>
<template>
<div class="pointer-events-auto flex flex-row items-start gap-2">
<div class="pointer-events-auto flex flex-col gap-2">
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<Button
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
</div>
</div>
<WorkflowActionsDropdown source="app_mode_toolbar" />
<Button
v-if="enableAppBuilder"
variant="base"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.buildAnApp')"
class="h-10 gap-1.5 rounded-lg px-3 font-normal"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
<span>{{ t('linearMode.appModeToolbar.buildAnApp') }}</span>
</Button>
</div>
</template>

View File

@@ -14,10 +14,7 @@
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<WorkflowActionsDropdown
v-if="!canvasStore.linearMode"
source="breadcrumb_subgraph_menu_selected"
/>
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
@@ -74,7 +71,6 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const canvasStore = useCanvasStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>

View File

@@ -11,6 +11,8 @@ import {
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
defineOptions({
@@ -50,11 +52,27 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</DropdownMenuSub>
<DropdownMenuItem
v-else
:class="itemClass"
v-tooltip="
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
"
:class="
cn(
itemClass,
String(item.class ?? ''),
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
)
"
v-bind="
'checked' in item
? { role: 'menuitemradio', 'aria-checked': Boolean(item.checked) }
: {}
"
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5 shrink-0" :class="item.icon" />
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<div

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -7,13 +8,16 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { ButtonVariants } from '../ui/button/button.variants'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
defineOptions({
inheritAttrs: false
})
@@ -41,10 +45,20 @@ const contentClass = computed(() =>
contentProp
)
)
// Body-portaled content keeps its static z-1700 unless a dialog that joined
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
// past that dialog so the menu isn't hidden behind it.
const open = ref(false)
const contentStyle = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
:style="contentStyle"
>
<slot :item-class>
<DropdownItem

View File

@@ -0,0 +1,56 @@
import { ZIndex } from '@primeuix/utils/zindex'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import DropdownMenu from './DropdownMenu.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderMenu() {
return render(DropdownMenu, {
props: { entries: [{ label: 'Item A' }] },
global: { plugins: [i18n], directives: { tooltip: {} } }
})
}
let openModal: HTMLElement | undefined
afterEach(() => {
if (openModal) {
ZIndex.clear(openModal)
openModal = undefined
}
})
describe('DropdownMenu z-index', () => {
it('opens above a dialog registered with the modal z-index counter', async () => {
openModal = document.createElement('div')
ZIndex.set('modal', openModal, 1700)
const dialogZ = Number(openModal.style.zIndex)
const user = userEvent.setup()
renderMenu()
await user.click(screen.getByRole('button'))
const menu = await screen.findByRole('menu')
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
})
it('leaves the static z-index untouched when no dialog is open', async () => {
const user = userEvent.setup()
renderMenu()
await user.click(screen.getByRole('button'))
const menu = await screen.findByRole('menu')
expect(menu.style.zIndex).toBe('')
expect(menu.className).toContain('z-1700')
})
})

View File

@@ -1,12 +1,11 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -18,67 +17,25 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
type ViewMode = 'graph' | 'app'
interface ViewModeSegment {
mode: ViewMode
icon: string
label: string
switchLabel: string
switchTooltip: string
active: boolean
}
const { source, align = 'start' } = defineProps<{
source: string
align?: 'start' | 'center' | 'end'
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const canvasStore = useCanvasStore()
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
const toggleShortcut = computed(() => {
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
})
const segments = computed<ViewModeSegment[]>(() => [
{
mode: 'graph',
icon: 'icon-[comfy--workflow]',
label: t('breadcrumbsMenu.graph'),
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
switchTooltip: t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value,
active: !canvasStore.displayLinearMode
},
{
mode: 'app',
icon: 'icon-[lucide--panels-top-left]',
label: t('breadcrumbsMenu.app'),
switchLabel: t('breadcrumbsMenu.enterAppMode'),
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value,
active: canvasStore.displayLinearMode
}
])
// Inactive segment first (left), active last (right). On mode switch the array
// reorders and TransitionGroup FLIP-animates the keyed nodes to their new spots.
const orderedSegments = computed(() =>
[...segments.value].sort((a, b) => Number(a.active) - Number(b.active))
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
@@ -89,32 +46,23 @@ function handleOpen(open: boolean) {
}
}
function switchMode() {
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
// The container is the dropdown trigger, so an inactive segment must stop its
// pointer event from bubbling up and opening the menu instead of switching.
function onSegmentPointerDown(seg: ViewModeSegment, e: PointerEvent) {
if (!seg.active) e.stopPropagation()
}
// Keyboard mirror of the pointer guard: stop Enter/Space on an inactive segment
// from bubbling to the trigger. The button's native activation still fires
// onSegmentClick to switch mode, so the menu stays closed.
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
if (!seg.active && (e.key === 'Enter' || e.key === ' ')) e.stopPropagation()
}
function onSegmentClick(seg: ViewModeSegment, e: MouseEvent) {
if (seg.active) return
e.stopPropagation()
switchMode()
}
const tooltipPt = {
root: {
style: {
@@ -127,7 +75,7 @@ const tooltipPt = {
style: { whiteSpace: 'nowrap' }
},
arrow: {
style: { left: '16px' }
class: '!left-[16px]'
}
}
</script>
@@ -138,81 +86,69 @@ const tooltipPt = {
:modal="false"
@update:open="handleOpen"
>
<DropdownMenuTrigger as-child>
<slot name="button" :has-unseen-items="hasUnseenItems">
<div
data-testid="view-mode-toggle"
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
>
<TransitionGroup
tag="div"
move-class="transition-[background-color,color,transform] duration-200"
class="flex items-center gap-1"
<Button
v-tooltip.bottom="{
value: toggleModeTooltip(),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
}"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
>
<Button
v-for="seg in orderedSegments"
:key="seg.mode"
v-tooltip.bottom="{
value: seg.active
? t('breadcrumbsMenu.workflowActions')
: seg.switchTooltip,
showDelay: 300,
hideDelay: 300,
pt: seg.active ? undefined : tooltipPt
}"
type="button"
variant="textonly"
size="unset"
:aria-label="
seg.active
? t('breadcrumbsMenu.workflowActions')
: seg.switchLabel
"
<i
class="size-4"
:class="
cn(
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
seg.active
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
)
canvasStore.linearMode
? 'icon-[lucide--panels-top-left]'
: 'icon-[comfy--workflow]'
"
@pointerdown="onSegmentPointerDown(seg, $event)"
@keydown="onSegmentKeydown(seg, $event)"
@click="onSegmentClick(seg, $event)"
/>
</Button>
<DropdownMenuTrigger as-child>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
<span
:class="
cn(
'grid transition-[grid-template-columns,opacity] duration-200',
seg.active
? 'ml-1.5 grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0'
)
"
>
<span
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
>
{{ seg.label }}
<i
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</span>
</span>
<span
v-if="seg.active && hasUnseenItems"
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</TransitionGroup>
</DropdownMenuTrigger>
</div>
</DropdownMenuTrigger>
</slot>
<DropdownMenuPortal>
<DropdownMenuContent
:align
:side-offset="8"
:side-offset="5"
:collision-padding="10"
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>

View File

@@ -37,7 +37,7 @@
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else @submit="signUpWithEmail" />
<SignUpForm v-else ref="signUpForm" @submit="signUpWithEmail" />
</template>
<!-- Divider -->
@@ -206,9 +206,21 @@ const signInWithEmail = async (values: SignInData) => {
}
}
const signUpWithEmail = async (values: SignUpData) => {
if (await authActions.signUpWithEmail(values.email, values.password)) {
const signUpForm = ref<InstanceType<typeof SignUpForm> | null>(null)
const signUpWithEmail = async (values: SignUpData, turnstileToken?: string) => {
if (
await authActions.signUpWithEmail(
values.email,
values.password,
turnstileToken
)
) {
onSuccess()
} else {
// Signup failed while the form is still mounted: re-arm the single-use
// Turnstile token so the next attempt sends a fresh one.
signUpForm.value?.resetTurnstile()
}
}

View File

@@ -0,0 +1,97 @@
<template>
<div class="credits-container flex h-full flex-col gap-4">
<div>
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
</div>
<CreditsTile />
<div class="flex items-center justify-between">
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
</Button>
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
<i class="pi pi-question-circle" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button variant="muted-textonly" @click="handleMessageSupport">
<i class="pi pi-comments" />
{{ $t('credits.messageSupport') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import { ref, watch } from 'vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useAuthStore } from '@/stores/authStore'
import { useCommandStore } from '@/stores/commandStore'
const { buildDocsUrl, docsPaths } = useExternalLink()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
void usageLogsTableRef.value.refresh()
}
}
)
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank',
'noopener,noreferrer'
)
}
</script>

View File

@@ -1,195 +0,0 @@
<template>
<div class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
<div class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-muted">
{{ $t('credits.yourCreditBalance') }}
</h3>
<div class="flex items-center justify-between">
<UserCredit text-class="text-3xl font-bold" />
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else-if="isActiveSubscription"
:loading="loading"
@click="handlePurchaseCreditsClick"
>
{{ $t('credits.purchaseCredits') }}
</Button>
</div>
<div class="flex flex-row items-center">
<Skeleton
v-if="balanceLoading"
width="12rem"
height="1rem"
class="text-xs"
/>
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
</div>
<Button
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.refresh')"
@click="() => authActions.fetchBalance()"
>
<i class="pi pi-refresh" />
</Button>
</div>
</div>
<div class="flex items-center justify-between">
<h3>{{ $t('credits.activity') }}</h3>
<Button
variant="muted-textonly"
:loading="loading"
@click="handleCreditsHistoryClick"
>
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>
<template v-if="creditHistory.length > 0">
<div class="grow">
<DataTable :value="creditHistory" :show-headers="false">
<Column field="title" :header="$t('g.name')">
<template #body="{ data }">
<div class="text-sm font-medium">{{ data.title }}</div>
<div class="text-xs text-muted">{{ data.timestamp }}</div>
</template>
</Column>
<Column field="amount" :header="$t('g.amount')">
<template #body="{ data }">
<div
:class="[
'text-center text-base font-medium',
data.isPositive ? 'text-sky-500' : 'text-red-400'
]"
>
{{ data.isPositive ? '+' : '-' }}${{
formatMetronomeCurrency(data.amount, 'usd')
}}
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<Divider />
<UsageLogsTable ref="usageLogsTableRef" />
<div class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
</Button>
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
<i class="pi pi-question-circle" />
{{ $t('subscription.partnerNodesCredits') }}
</Button>
<Button variant="muted-textonly" @click="handleMessageSupport">
<i class="pi pi-comments" />
{{ $t('credits.messageSupport') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useAuthStore } from '@/stores/authStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
title: string
timestamp: string
amount: number
isPositive: boolean
}
const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
const formattedLastUpdateTime = computed(() =>
authStore.lastBalanceUpdateTime
? authStore.lastBalanceUpdateTime.toLocaleString()
: ''
)
watch(
() => authStore.lastBalanceUpdateTime,
(newTime, oldTime) => {
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
usageLogsTableRef.value.refresh()
}
}
)
const handlePurchaseCreditsClick = () => {
// Track purchase credits entry from Settings > Credits panel
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
}
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
}
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

View File

@@ -1,12 +1,13 @@
import { Form, FormField } from '@primevue/forms'
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import PrimeVue from 'primevue/config'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -36,34 +37,116 @@ vi.mock('@/stores/authStore', () => ({
}))
}))
const mockTurnstileEnabled = ref(false)
const mockTurnstileEnforced = ref(false)
const mockReset = vi.fn()
let emitTurnstileToken: ((token: string) => void) | undefined
vi.mock('@/composables/auth/useTurnstile', () => ({
useTurnstile: () => ({
enabled: mockTurnstileEnabled,
enforced: mockTurnstileEnforced
})
}))
// Stub the real widget (which loads the external Turnstile script) with one that
// exposes a spyable reset() and lets a test drive the v-model token the way a
// solved challenge would.
vi.mock('./TurnstileWidget.vue', async () => {
const { defineComponent: defineMock } = await import('vue')
return {
default: defineMock({
name: 'TurnstileWidget',
emits: ['update:token'],
setup(_, { expose, emit }) {
expose({ reset: mockReset })
emitTurnstileToken = (token: string) => emit('update:token', token)
return () => null
}
})
}
})
const signUpButton = enMessages.auth.signup.signUpButton
function globalOptions() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return {
plugins: [PrimeVue, i18n],
components: {
Form,
FormField,
Button,
InputText,
Password,
ProgressSpinner
}
}
}
describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
mockTurnstileEnabled.value = false
mockTurnstileEnforced.value = false
mockReset.mockClear()
emitTurnstileToken = undefined
})
afterEach(() => {
vi.restoreAllMocks()
})
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(SignUpForm, {
global: {
plugins: [PrimeVue, i18n],
components: {
Form,
FormField,
Button,
InputText,
Password,
ProgressSpinner
}
function renderComponent(props: Record<string, unknown> = {}) {
const user = userEvent.setup()
const utils = render(SignUpForm, { global: globalOptions(), props })
return { ...utils, user }
}
/** Render through a host that keeps a ref, so the parent-facing exposed
* `resetTurnstile()` can be invoked the way SignInContent would. */
function renderWithRef() {
const formRef = ref<{ resetTurnstile: () => void } | null>(null)
const Host = defineComponent({
setup() {
return () => h(SignUpForm, { ref: formRef })
}
})
const utils = render(Host, { global: globalOptions() })
return {
...utils,
form: () => {
if (!formRef.value) throw new Error('form not mounted')
return formRef.value
}
}
}
const expectedValues = {
email: 'new@example.com',
password: 'Password1!',
confirmPassword: 'Password1!'
}
async function fillValidSignup(user: ReturnType<typeof userEvent.setup>) {
await user.type(
screen.getByPlaceholderText(enMessages.auth.signup.emailPlaceholder),
expectedValues.email
)
await user.type(
screen.getByPlaceholderText(enMessages.auth.signup.passwordPlaceholder),
expectedValues.password
)
await user.type(
screen.getByPlaceholderText(
enMessages.auth.login.confirmPasswordPlaceholder
),
expectedValues.confirmPassword
)
}
describe('Password manager autofill attributes', () => {
@@ -107,4 +190,97 @@ describe('SignUpForm', () => {
)
})
})
describe('Turnstile single-use token reset', () => {
it('exposes resetTurnstile() that resets the rendered widget', async () => {
mockTurnstileEnabled.value = true
const { form } = renderWithRef()
await nextTick()
form().resetTurnstile()
expect(mockReset).toHaveBeenCalledOnce()
})
it('does not reset the widget on the initial render', async () => {
mockTurnstileEnabled.value = true
renderWithRef()
await nextTick()
expect(mockReset).not.toHaveBeenCalled()
})
})
describe('Turnstile token hygiene', () => {
it('clears the stale token when Turnstile becomes disabled', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const { user } = renderComponent()
await fillValidSignup(user)
emitTurnstileToken!('stale-token')
await nextTick()
expect(
screen.getByRole('button', { name: signUpButton })
).not.toBeDisabled()
mockTurnstileEnabled.value = false
await nextTick()
// re-enable: the stale token must have been cleared so submit is blocked again
mockTurnstileEnabled.value = true
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
})
describe('Turnstile submit gating', () => {
it('disables the submit button in enforce mode until a token is present', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
renderComponent()
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
it('does not emit submit in enforce mode while the token is empty', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).not.toHaveBeenCalled()
})
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
emitTurnstileToken!('token-xyz')
await nextTick()
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
})
it('emits submit without a token in shadow mode (never blocks)', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = false
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
})
})
})

View File

@@ -29,13 +29,34 @@
<PasswordFields />
<TurnstileWidget
v-if="turnstileEnabled"
ref="turnstileWidget"
v-model:token="turnstileToken"
/>
<small
v-show="submitBlockedByTurnstile"
id="comfy-org-sign-up-turnstile-hint"
role="status"
aria-live="polite"
class="opacity-80"
>
{{ t('auth.turnstile.submitBlockedHint') }}
</small>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="mx-auto size-8" />
<Button
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid"
:disabled="!$form.valid || submitBlockedByTurnstile"
:aria-describedby="
submitBlockedByTurnstile
? 'comfy-org-sign-up-turnstile-hint'
: undefined
"
>
{{ t('auth.signup.signUpButton') }}
</Button>
@@ -49,27 +70,58 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTurnstile } from '@/composables/auth/useTurnstile'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
import PasswordFields from './PasswordFields.vue'
import TurnstileWidget from './TurnstileWidget.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
useTurnstile()
const turnstileToken = ref('')
const turnstileWidget =
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
const submitBlockedByTurnstile = computed(
() => turnstileEnforced.value && !turnstileToken.value
)
watch(turnstileEnabled, (on) => {
if (!on) turnstileToken.value = ''
})
const emit = defineEmits<{
submit: [values: SignUpData]
submit: [values: SignUpData, turnstileToken?: string]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignUpData)
if (event.valid && !submitBlockedByTurnstile.value) {
emit(
'submit',
event.values as SignUpData,
turnstileToken.value || undefined
)
}
}, 1_500)
// Turnstile tokens are single-use. The parent calls this after a FAILED signup
// (the form can't observe the submit outcome itself) to discard the spent token
// and request a fresh challenge. Driving it from the actual result — instead of
// watching the store-global loading flag — keeps an unrelated auth action from
// wiping a freshly-solved token, and avoids resetting a widget that is about to
// unmount on success.
function resetTurnstile() {
turnstileWidget.value?.reset()
}
defineExpose({ resetTurnstile })
</script>

View File

@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { render } from '@testing-library/vue'
import type { TurnstileRenderOptions } from '@/composables/auth/turnstileScript'
import TurnstileWidget from './TurnstileWidget.vue'
const { mockLoadTurnstile, mockGetSiteKey, mockLightTheme } = vi.hoisted(
() => ({
mockLoadTurnstile: vi.fn(),
mockGetSiteKey: vi.fn(() => 'site-key'),
mockLightTheme: { value: true }
})
)
vi.mock('@/composables/auth/turnstileScript', () => ({
loadTurnstile: mockLoadTurnstile
}))
vi.mock('@/config/turnstile', () => ({
getTurnstileSiteKey: mockGetSiteKey
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
get light_theme() {
return mockLightTheme.value
}
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
auth: {
turnstile: {
expired: 'Challenge expired',
failed: 'Verification failed'
}
}
}
}
})
/** A controllable Cloudflare Turnstile global whose render() captures options. */
function fakeTurnstile() {
let captured: TurnstileRenderOptions | undefined
const api = {
render: vi.fn((_el: unknown, options: TurnstileRenderOptions) => {
captured = options
return 'widget-id'
}),
reset: vi.fn(),
remove: vi.fn()
}
return { api, options: () => captured }
}
/** Drain the onMounted async (loadTurnstile) plus any follow-up microtasks. */
const flush = async () => {
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve))
}
const renderWidget = () =>
render(TurnstileWidget, { global: { plugins: [i18n] } })
/**
* Render TurnstileWidget through a thin host that keeps a ref to it, so the
* exposed `reset()` method can be invoked the way a parent (SignUpForm) would.
*/
const renderWidgetWithExpose = () => {
const widgetRef = ref<{ reset: () => void } | null>(null)
const Host = defineComponent({
setup(_, { emit }) {
return () =>
h(TurnstileWidget, {
ref: widgetRef,
'onUpdate:token': (value: string) => emit('update:token', value)
})
}
})
const utils = render(Host, { global: { plugins: [i18n] } })
return {
...utils,
getCurrentInstance: () => {
if (!widgetRef.value) throw new Error('widget not mounted')
return widgetRef.value
}
}
}
describe('TurnstileWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSiteKey.mockReturnValue('site-key')
mockLightTheme.value = true
delete window.turnstile
})
afterEach(() => {
delete window.turnstile
})
it('renders the widget with the configured sitekey and light theme', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
renderWidget()
await flush()
expect(mockLoadTurnstile).toHaveBeenCalledOnce()
expect(api.render).toHaveBeenCalledOnce()
expect(options()?.sitekey).toBe('site-key')
expect(options()?.theme).toBe('light')
})
it('uses the dark theme when the active palette is not light', async () => {
mockLightTheme.value = false
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
renderWidget()
await flush()
expect(options()?.theme).toBe('dark')
})
it('emits the solved token via v-model and shows no error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted, container } = renderWidget()
await flush()
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
expect(container.textContent).not.toContain('Challenge expired')
expect(container.textContent).not.toContain('Verification failed')
})
it('clears the token and surfaces the expired message on expiry', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted, container } = renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['expired-callback']!()
await flush()
expect(emitted()['update:token'].at(-1)).toEqual([''])
expect(container.textContent).toContain('Challenge expired')
})
it('clears the token and surfaces the failure message on widget error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted, container } = renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['error-callback']!()
await flush()
expect(emitted()['update:token'].at(-1)).toEqual([''])
expect(container.textContent).toContain('Verification failed')
})
it('resets the widget on a challenge error to fetch a fresh challenge', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
})
it('shows the failure message when the Turnstile script fails to load', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { container } = renderWidget()
await flush()
expect(container.textContent).toContain('Verification failed')
})
it('reset() clears the token model and resets the rendered widget', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
await flush()
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
getCurrentInstance().reset()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
expect(emitted()['update:token'].at(-1)).toEqual([''])
})
it('reset() clears a stale error so it does not linger over a fresh challenge', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { container, getCurrentInstance } = renderWidgetWithExpose()
await flush()
options()!['error-callback']!()
await flush()
expect(container.textContent).toContain('Verification failed')
getCurrentInstance().reset()
await flush()
expect(container.textContent).not.toContain('Verification failed')
})
it('reset() clears the token even when the widget never rendered', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
await flush()
getCurrentInstance().reset()
await flush()
// No widget id was captured, so window.turnstile.reset is never called,
// but the token model is still cleared.
expect(emitted()['update:token']?.at(-1) ?? ['']).toEqual([''])
})
it('removes the widget on unmount when one was rendered', async () => {
const { api } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { unmount } = renderWidget()
await flush()
unmount()
expect(api.remove).toHaveBeenCalledWith('widget-id')
})
})

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col gap-2">
<div ref="containerRef"></div>
<small
v-if="errorMessage"
role="alert"
aria-live="assertive"
class="text-red-500"
>{{ errorMessage }}</small
>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { loadTurnstile } from '@/composables/auth/turnstileScript'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const token = defineModel<string>('token', { default: '' })
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const containerRef = ref<HTMLDivElement>()
const errorMessage = ref('')
let widgetId: string | undefined
const clearToken = () => {
token.value = ''
}
/**
* Fetch a fresh challenge and clear the current token.
*
* Turnstile tokens are single-use, so after a token is consumed by a submit
* attempt that did not succeed, the spent token must be discarded and a new
* challenge requested. Clearing the model re-blocks submission until the user
* solves the fresh challenge; clearing the error drops any stale failure text
* so it can't linger over the new challenge.
*/
const reset = () => {
clearToken()
errorMessage.value = ''
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
}
}
defineExpose({ reset })
onMounted(async () => {
try {
const turnstile = await loadTurnstile()
if (!containerRef.value) return
const theme = colorPaletteStore.completedActivePalette.light_theme
? 'light'
: 'dark'
widgetId = turnstile.render(containerRef.value, {
sitekey: getTurnstileSiteKey(),
theme,
callback: (newToken: string) => {
errorMessage.value = ''
token.value = newToken
},
'expired-callback': () => {
clearToken()
errorMessage.value = t('auth.turnstile.expired')
},
'error-callback': () => {
clearToken()
console.warn('Turnstile challenge failed')
errorMessage.value = t('auth.turnstile.failed')
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
}
})
} catch (error) {
console.warn('Turnstile failed to load', error)
errorMessage.value = t('auth.turnstile.failed')
}
})
onBeforeUnmount(() => {
if (widgetId && window.turnstile) {
window.turnstile.remove(widgetId)
}
})
</script>

View File

@@ -18,8 +18,8 @@
</div>
</div>
</template>
<template #side-toolbar>
<SideToolbar v-if="showUI && !isBuilderMode && !linearMode" />
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI" #side-bar-panel>
<div
@@ -195,10 +195,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
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'
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
const { t } = useI18n()
const emit = defineEmits<{
@@ -457,10 +454,7 @@ useEventListener(
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
const { runUrlActionLoaders } = useUrlActionLoaders()
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
@@ -569,23 +563,8 @@ onMounted(async () => {
() => canvasStore.updateSelectedItems()
)
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
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
)
}
}
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
await runUrlActionLoaders()
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } =

View File

@@ -26,7 +26,6 @@ const singleErrorCard: ErrorCardData = {
title: 'CLIPTextEncode',
nodeId: createNodeExecutionId([10]),
nodeTitle: 'CLIP Text Encode (Prompt)',
isSubgraphNode: false,
errors: [
{
message: 'Required input "text" is missing.',
@@ -40,7 +39,6 @@ const multipleErrorsCard: ErrorCardData = {
title: 'VAEDecode',
nodeId: createNodeExecutionId([24]),
nodeTitle: 'VAE Decode',
isSubgraphNode: false,
errors: [
{
message: 'Required input "samples" is missing.',
@@ -58,7 +56,6 @@ const runtimeErrorCard: ErrorCardData = {
title: 'KSampler',
nodeId: createNodeExecutionId([45]),
nodeTitle: 'KSampler',
isSubgraphNode: false,
errors: [
{
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
@@ -73,20 +70,6 @@ const runtimeErrorCard: ErrorCardData = {
]
}
const subgraphErrorCard: ErrorCardData = {
id: 'node-3:15',
title: 'KSampler',
nodeId: createNodeExecutionId([3, 15]),
nodeTitle: 'Nested KSampler',
isSubgraphNode: true,
errors: [
{
message: 'Latent input is required.',
details: ''
}
]
}
const promptOnlyCard: ErrorCardData = {
id: '__prompt__',
title: 'Prompt has no outputs.',
@@ -104,13 +87,6 @@ export const SingleValidationError: Story = {
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {

View File

@@ -79,7 +79,6 @@ describe('ErrorNodeCard.vue', () => {
},
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:

View File

@@ -21,15 +21,6 @@
</span>
</span>
<div class="flex shrink-0 items-center">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
v-if="hasRuntimeError"
variant="textonly"
@@ -202,7 +193,6 @@ const { card, compact = false } = defineProps<{
const emit = defineEmits<{
locateNode: [nodeId: string]
enterSubgraph: [nodeId: string]
copyToClipboard: [text: string]
}>()
@@ -233,12 +223,6 @@ function handleLocateNode() {
}
}
function handleEnterSubgraph() {
if (card.nodeId) {
emit('enterSubgraph', card.nodeId)
}
}
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = getCopyMessage(card.errors[idx])

View File

@@ -11,7 +11,6 @@ import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
const mockFocusNode = vi.hoisted(() => vi.fn())
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({
app: {
@@ -35,16 +34,9 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
fitView: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: mockFocusNode,
enterSubgraph: mockEnterSubgraph
focusNode: mockFocusNode
}))
}))

View File

@@ -249,7 +249,6 @@
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
@@ -357,7 +356,7 @@ const ErrorPanelSurveyCta =
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { focusNode } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
@@ -523,8 +522,4 @@ function handleReplaceGroup(group: SwapNodeGroup) {
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
</script>

View File

@@ -16,7 +16,6 @@ export interface ErrorCardData {
nodeId?: NodeExecutionId
nodeTitle?: string
graphNodeId?: string
isSubgraphNode?: boolean
errors: ErrorItem[]
}

View File

@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('marks only nested execution paths as subgraph node cards', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.cards).toMatchObject([
{ nodeId: '1', isSubgraphNode: false },
{ nodeId: '1:20', isSubgraphNode: true }
])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -130,7 +130,6 @@ function createErrorCard(
nodeId,
nodeTitle: nodeInfo.title,
graphNodeId: nodeInfo.graphNodeId,
isSubgraphNode: nodeId.includes(':'),
errors: []
}
}

View File

@@ -42,14 +42,8 @@
:is-small="isSmall"
/>
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton
v-if="!isCloud && !canvasStore.linearMode"
:is-small="isSmall"
/>
<SidebarShortcutsToggleButton
v-if="!canvasStore.linearMode"
:is-small="isSmall"
/>
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
@@ -95,11 +89,6 @@ import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const { visibleTabIds, forceConnected = false } = defineProps<{
visibleTabIds?: string[]
forceConnected?: boolean
}>()
const NightlySurveyController =
isNightly && !isCloud && !isDesktop
? defineAsyncComponent(
@@ -126,18 +115,12 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isConnected = computed(
() =>
forceConnected ||
selectedTab.value ||
isOverflowing.value ||
sidebarStyle.value === 'connected'
)
const tabs = computed(() => {
const all = workspaceStore.getSidebarTabs()
return visibleTabIds
? all.filter((tab) => visibleTabIds.includes(tab.id))
: all
})
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
/**

View File

@@ -1,23 +1,5 @@
<template>
<Popover
v-if="linearMode"
:side="sidebarOnLeft ? 'right' : 'left'"
:side-offset="8"
>
<template #button>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
:label="$t('menu.help')"
:tooltip="$t('linearMode.giveFeedback')"
:is-small="isSmall"
/>
</template>
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget="typeformId" />
</Popover>
<SidebarIcon
v-else
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
@@ -31,34 +13,13 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import { useHelpCenter } from '@/composables/useHelpCenter'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTypeformEmbed } from '@/platform/surveys/useTypeformEmbed'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import SidebarIcon from './SidebarIcon.vue'
const APP_MODE_FEEDBACK_TYPEFORM_ID = 'jmmzmlKw'
defineProps<{
isSmall: boolean
}>()
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
const { linearMode } = storeToRefs(useCanvasStore())
const settingStore = useSettingStore()
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
const { typeformId } = useTypeformEmbed(
feedbackRef,
APP_MODE_FEEDBACK_TYPEFORM_ID
)
</script>

View File

@@ -13,26 +13,18 @@
{{ $t('g.beta') }}
</span>
</template>
<template #header-actions="{ hasResults }">
<Button
v-if="hasResults"
variant="secondary"
size="md"
:aria-label="$t('linearMode.appModeToolbar.create')"
@click="createApp"
>
<i class="icon-[lucide--plus] size-4" aria-hidden="true" />
{{ $t('linearMode.appModeToolbar.create') }}
</Button>
</template>
<template #empty-state>
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="`${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`"
button-icon="icon-[lucide--plus]"
:button-label="$t('linearMode.appModeToolbar.createApp')"
@action="createApp"
:message="
isAppMode
? $t('linearMode.appModeToolbar.appsEmptyMessage')
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
"
button-icon="icon-[lucide--hammer]"
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
@action="enterAppMode"
/>
</template>
</BaseWorkflowsSidebarTab>
@@ -41,17 +33,16 @@
<script setup lang="ts">
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
const commandStore = useCommandStore()
const { isAppMode, setMode } = useAppMode()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function createApp() {
void commandStore.execute('Comfy.NewBlankWorkflow')
function enterAppMode() {
setMode('app')
}
</script>

View File

@@ -30,10 +30,6 @@
"
/>
</Button>
<slot
name="header-actions"
:has-results="filteredPersistedWorkflows.length > 0"
/>
</template>
<template #header>
<SidebarTopArea>

View File

@@ -1,37 +1,18 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h, ref } from 'vue'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import type { BalanceInfo, SubscriptionInfo } from '@/composables/billing/types'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock the settings dialog composable
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
useSettingsDialog: vi.fn(() => ({
show: mockShowSettingsDialog,
@@ -40,7 +21,6 @@ vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
}))
}))
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
@@ -50,7 +30,6 @@ afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
const mockHandleSignOut = vi.fn()
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
@@ -61,60 +40,50 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
effective_balance_micros: 100_000,
currency: 'usd'
} as {
amount_micros?: number
effective_balance_micros?: number
currency: string
},
isFetchingBalance: false
}))
function makeSubscription(
overrides: Partial<SubscriptionInfo> = {}
): SubscriptionInfo {
return {
isActive: true,
tier: 'CREATOR',
duration: 'MONTHLY',
planSlug: null,
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true,
...overrides
}
}
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: mockAuthStoreState.balance,
isFetchingBalance: mockAuthStoreState.isFetchingBalance
}))
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
const mockFetchBalance = vi.fn().mockResolvedValue(undefined)
const mockIsActiveSubscription = ref(true)
const mockIsFreeTier = ref(false)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: ref(true),
const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
const mockBalance = ref<BalanceInfo | null>(null)
const mockIsLoading = ref(false)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
isActiveSubscription: mockIsActiveSubscription,
isFreeTier: mockIsFreeTier,
subscriptionTierName: ref('Creator'),
subscriptionTier: ref('CREATOR'),
fetchStatus: mockFetchStatus
tier: mockTier,
subscription: mockSubscription,
balance: mockBalance,
isLoading: mockIsLoading,
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
}))
}))
// Mock the useSubscriptionDialog composable
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
@@ -127,7 +96,6 @@ vi.mock(
})
)
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
@@ -137,22 +105,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`),
@@ -162,14 +118,12 @@ vi.mock('@/composables/useExternalLink', () => ({
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud with hoisted state for per-test toggling
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -178,25 +132,37 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
default: defineComponent({
name: 'SubscribeButtonMock',
render() {
return h('div', 'Subscribe Button')
emits: ['subscribed'],
setup(_, { emit }) {
return () =>
h(
'button',
{
'data-testid': 'subscribe-button-mock',
onClick: () => emit('subscribed')
},
'Subscribe Button'
)
}
}
})
}))
describe('CurrentUserPopoverLegacy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsActiveSubscription.value = true
mockIsFreeTier.value = false
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 100_000,
mockTier.value = 'CREATOR'
mockSubscription.value = makeSubscription()
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 100_000,
currency: 'usd'
}
mockAuthStoreState.isFetchingBalance = false
mockIsLoading.value = false
})
function renderComponent() {
@@ -230,7 +196,47 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('test@example.com')).toBeInTheDocument()
})
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
it('fetches the balance through the billing facade on mount', () => {
renderComponent()
expect(mockFetchBalance).toHaveBeenCalled()
})
it('refreshes subscription status through the billing facade after subscribing', async () => {
mockIsActiveSubscription.value = false
const { user } = renderComponent()
await user.click(screen.getByTestId('subscribe-button-mock'))
expect(mockFetchStatus).toHaveBeenCalled()
})
describe('subscription tier badge', () => {
it('renders the tier name derived from the facade tier', () => {
renderComponent()
expect(screen.getByText('Creator')).toBeInTheDocument()
})
it('renders the yearly tier name when the facade subscription is annual', () => {
mockSubscription.value = makeSubscription({ duration: 'ANNUAL' })
renderComponent()
expect(screen.getByText('Creator Yearly')).toBeInTheDocument()
})
it('hides the badge when the facade reports no tier', () => {
mockTier.value = null
mockSubscription.value = null
renderComponent()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
})
})
it('formats and displays the facade balance', () => {
renderComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
@@ -245,6 +251,14 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('shows a skeleton instead of the balance while billing is loading', () => {
mockIsLoading.value = true
renderComponent()
expect(screen.queryByText('1000')).not.toBeInTheDocument()
})
it('renders logout menu item with correct text', () => {
renderComponent()
@@ -324,11 +338,11 @@ describe('CurrentUserPopoverLegacy', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
describe('effective_balance_micros handling', () => {
it('uses effective_balance_micros when present (positive balance)', () => {
mockAuthStoreState.balance = {
amount_micros: 200_000,
effective_balance_micros: 150_000,
describe('facade balance handling', () => {
it('uses effectiveBalanceMicros when present (positive balance)', () => {
mockBalance.value = {
amountMicros: 200_000,
effectiveBalanceMicros: 150_000,
currency: 'usd'
}
@@ -345,10 +359,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1500')).toBeInTheDocument()
})
it('uses effective_balance_micros when zero', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
effective_balance_micros: 0,
it('uses effectiveBalanceMicros when zero', () => {
mockBalance.value = {
amountMicros: 100_000,
effectiveBalanceMicros: 0,
currency: 'usd'
}
@@ -365,10 +379,10 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('0')).toBeInTheDocument()
})
it('uses effective_balance_micros when negative', () => {
mockAuthStoreState.balance = {
amount_micros: 0,
effective_balance_micros: -50_000,
it('uses effectiveBalanceMicros when negative', () => {
mockBalance.value = {
amountMicros: 0,
effectiveBalanceMicros: -50_000,
currency: 'usd'
}
@@ -385,9 +399,9 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('-500')).toBeInTheDocument()
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
mockAuthStoreState.balance = {
amount_micros: 100_000,
it('falls back to amountMicros when effectiveBalanceMicros is missing', () => {
mockBalance.value = {
amountMicros: 100_000,
currency: 'usd'
}
@@ -404,10 +418,8 @@ describe('CurrentUserPopoverLegacy', () => {
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
mockAuthStoreState.balance = {
currency: 'usd'
}
it('falls back to 0 when the facade reports no balance', () => {
mockBalance.value = null
renderComponent()
@@ -466,8 +478,11 @@ describe('CurrentUserPopoverLegacy', () => {
})
it('hides subscribe button', () => {
mockIsActiveSubscription.value = false
renderComponent()
expect(screen.queryByText('Subscribe Button')).not.toBeInTheDocument()
expect(
screen.queryByTestId('subscribe-button-mock')
).not.toBeInTheDocument()
})
it('still shows partner nodes menu item', () => {

View File

@@ -32,12 +32,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
}}</span>
@@ -162,16 +157,15 @@ 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 { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -181,25 +175,29 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
} = useSubscription()
tier,
subscription,
balance,
isLoading,
fetchStatus,
fetchBalance
} = useBillingContext()
const { formatTierName } = useWorkspaceTierLabel()
const subscriptionDialog = useSubscriptionDialog()
const { locale } = useI18n()
const subscriptionTierName = computed(() =>
formatTierName(tier.value, subscription.value?.duration === 'ANNUAL')
)
const formattedBalance = computed(() => {
const cents =
authStore.balance?.effective_balance_micros ??
authStore.balance?.amount_micros ??
0
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
@@ -211,12 +209,12 @@ const formattedBalance = computed(() => {
})
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
const currentTier = tier.value
return (
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
currentTier === 'FREE' ||
currentTier === 'FOUNDERS_EDITION' ||
currentTier === 'STANDARD' ||
currentTier === 'CREATOR'
)
})
@@ -270,6 +268,6 @@ const handleSubscribed = async () => {
}
onMounted(() => {
void authActions.fetchBalance()
void fetchBalance()
})
</script>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
const { active = true } = defineProps<{
dataTfWidget: string
active?: boolean
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
scriptEl.src = '//embed.typeform.com/next/embed.js'
feedbackRef.value?.appendChild(scriptEl)
})
</script>
<template>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
<Popover v-else>
<template #button>
<Button
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
</template>
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
</Popover>
</template>

View File

@@ -22,6 +22,8 @@ export const buttonVariants = cva({
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
@@ -54,6 +56,7 @@ const variants = [
'destructive-textonly',
'link',
'base',
'tertiary',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

View File

@@ -13,7 +13,8 @@ import { cn } from '@comfyorg/tailwind-utils'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
TEAM_PLAN_CREDIT_STOPS
TEAM_PLAN_CREDIT_STOPS,
getStopDiscountedMonthlyUsd
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
@@ -83,7 +84,7 @@ const effectiveDiscountPercent = computed(() =>
: current.value.discountPercentYearly
)
const discountedMonthly = computed(() =>
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
getStopDiscountedMonthlyUsd(current.value, cycle)
)
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)

View File

@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TurnstileApi } from '@/composables/auth/turnstileScript'
const TURNSTILE_SRC =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
const fakeApi = (): TurnstileApi => ({
render: vi.fn(() => 'widget-id'),
reset: vi.fn(),
remove: vi.fn()
})
/**
* Controllable stand-in for the injected <script>. We never insert a real
* external script because jsdom would try (and fail) to fetch it and fire its
* own `error` event, making `load` impossible to simulate deterministically.
* Instead `createElement`/`querySelector`/`appendChild` are spied to route
* through this fake, so the test drives `load`/`error`/timeout itself.
*/
class FakeScript {
src = ''
async = false
private handlers: Record<string, Array<(e: Event) => void>> = {}
addEventListener(type: string, cb: (e: Event) => void) {
;(this.handlers[type] ??= []).push(cb)
}
dispatchEvent(event: Event): boolean {
for (const cb of this.handlers[event.type] ?? []) cb(event)
return true
}
remove() {
const i = inserted.indexOf(this)
if (i >= 0) inserted.splice(i, 1)
}
}
let inserted: FakeScript[] = []
const scriptEl = (): FakeScript | null =>
inserted.find((s) => s.src === TURNSTILE_SRC) ?? null
const scriptCount = () => inserted.filter((s) => s.src === TURNSTILE_SRC).length
/**
* The module keeps a private singleton promise, so each test imports a fresh
* copy after `vi.resetModules()`.
*/
async function freshLoadTurnstile() {
vi.resetModules()
const mod = await import('@/composables/auth/turnstileScript')
return mod.loadTurnstile
}
describe('loadTurnstile', () => {
beforeEach(() => {
inserted = []
delete window.turnstile
const realCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) =>
tag === 'script'
? (new FakeScript() as unknown as HTMLElement)
: realCreateElement(tag)
)
vi.spyOn(document, 'querySelector').mockImplementation((sel: string) =>
typeof sel === 'string' && sel.includes('challenges.cloudflare.com')
? (scriptEl() as unknown as Element | null)
: null
)
vi.spyOn(document.head, 'appendChild').mockImplementation((node: Node) => {
inserted.push(node as unknown as FakeScript)
return node
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('resolves immediately with the existing global and appends no script', async () => {
const api = fakeApi()
window.turnstile = api
const loadTurnstile = await freshLoadTurnstile()
await expect(loadTurnstile()).resolves.toBe(api)
expect(scriptEl()).toBeNull()
})
it('appends the script and resolves once it loads and exposes the global', async () => {
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
const el = scriptEl()
expect(el).not.toBeNull()
expect(el?.async).toBe(true)
const api = fakeApi()
window.turnstile = api
el!.dispatchEvent(new Event('load'))
await expect(promise).resolves.toBe(api)
})
it('caches the in-flight promise so concurrent callers share one load', async () => {
const loadTurnstile = await freshLoadTurnstile()
const p1 = loadTurnstile()
const p2 = loadTurnstile()
expect(p1).toBe(p2)
expect(scriptCount()).toBe(1)
})
it('polls for the global when it is published asynchronously after the load event', async () => {
vi.useFakeTimers()
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
scriptEl()!.dispatchEvent(new Event('load'))
// global published shortly after load
const api = fakeApi()
window.turnstile = api
await vi.advanceTimersByTimeAsync(50)
await expect(promise).resolves.toBe(api)
// tag stays in place on success
expect(scriptEl()).not.toBeNull()
})
it('rejects and clears the cache when the global never appears after load (poll timeout)', async () => {
vi.useFakeTimers()
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
scriptEl()!.dispatchEvent(new Event('load'))
// global never published; deadline elapses
const assertion = expect(promise).rejects.toThrow(/timed out/i)
await vi.advanceTimersByTimeAsync(10_000)
await assertion
// dead tag is removed so a later retry starts clean
expect(scriptEl()).toBeNull()
// cache was reset → a later call starts a brand-new load
const retry = loadTurnstile()
expect(retry).not.toBe(promise)
// settle the throwaway retry so it doesn't leak a 10s timer
retry.catch(() => {})
scriptEl()!.dispatchEvent(new Event('error'))
await expect(retry).rejects.toThrow()
})
it('rejects, removes the self-appended script, and clears the cache on load error', async () => {
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
scriptEl()!.dispatchEvent(new Event('error'))
await expect(promise).rejects.toThrow(/failed to load/i)
expect(scriptEl()).toBeNull()
// cache was reset → a later call starts a brand-new load
const retry = loadTurnstile()
expect(retry).not.toBe(promise)
// settle the throwaway retry so it doesn't leak a 10s timer
retry.catch(() => {})
scriptEl()!.dispatchEvent(new Event('error'))
await expect(retry).rejects.toThrow()
})
it('rejects, removes the script, and clears the cache on timeout', async () => {
vi.useFakeTimers()
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
const assertion = expect(promise).rejects.toThrow(/timed out/i)
vi.advanceTimersByTime(10_000)
await assertion
expect(scriptEl()).toBeNull()
})
it('reuses a pre-existing script tag and resolves promptly once the global appears (no duplicate, tag left in place)', async () => {
vi.useFakeTimers()
const existing = new FakeScript()
existing.src = TURNSTILE_SRC
inserted.push(existing)
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
// no duplicate appended
expect(scriptCount()).toBe(1)
// The pre-existing tag's load event may have already fired before we
// attached listeners, so resolution must come from polling for the global
// rather than from a (dead) load event.
const api = fakeApi()
window.turnstile = api
await vi.advanceTimersByTimeAsync(50)
await expect(promise).resolves.toBe(api)
// a pre-existing tag is left alone (never removed by this loader)
expect(scriptEl()).not.toBeNull()
})
it('reuses a pre-existing script tag and times out (clearing the cache) if the global never appears, leaving the tag in place', async () => {
vi.useFakeTimers()
const existing = new FakeScript()
existing.src = TURNSTILE_SRC
inserted.push(existing)
const loadTurnstile = await freshLoadTurnstile()
const promise = loadTurnstile()
const assertion = expect(promise).rejects.toThrow(/timed out/i)
await vi.advanceTimersByTimeAsync(10_000)
await assertion
// pre-existing tag is never removed by the loader
expect(scriptEl()).not.toBeNull()
// cache was reset → a later call starts a brand-new load
const retry = loadTurnstile()
expect(retry).not.toBe(promise)
// drain the throwaway retry's timer/promise so nothing leaks
retry.catch(() => {})
await vi.advanceTimersByTimeAsync(10_000)
})
})

View File

@@ -0,0 +1,36 @@
import { createScriptLoader } from '@/utils/loadExternalScript'
const TURNSTILE_SRC =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
export interface TurnstileRenderOptions {
sitekey: string
theme?: 'light' | 'dark' | 'auto'
callback?: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
}
export interface TurnstileApi {
render: (
container: string | HTMLElement,
options: TurnstileRenderOptions
) => string
reset: (widgetId?: string) => void
remove: (widgetId: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
}
}
const loadTurnstileScript = createScriptLoader(
TURNSTILE_SRC,
() => window.turnstile ?? null
)
export function loadTurnstile(): Promise<TurnstileApi> {
return loadTurnstileScript()
}

View File

@@ -199,8 +199,8 @@ export const useAuthActions = () => {
)
const signUpWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string) => {
return await authStore.register(email, password)
async (email: string, password: string, turnstileToken?: string) => {
return await authStore.register(email, password, turnstileToken)
},
reportError
)

View File

@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
isTurnstileEnabled,
normalizeTurnstileMode,
useTurnstile
} from '@/composables/auth/useTurnstile'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: {} }
}))
vi.mock('@/scripts/api', () => ({
api: { getServerFeature: vi.fn() }
}))
vi.mock('@/utils/devFeatureFlagOverride', () => ({
getDevOverride: vi.fn()
}))
vi.mock('@/config/turnstile', () => ({
getTurnstileSiteKey: vi.fn()
}))
const mockedDevOverride = vi.mocked(getDevOverride)
const mockedGetServerFeature = vi.mocked(api.getServerFeature)
const mockedSiteKey = vi.mocked(getTurnstileSiteKey)
describe('normalizeTurnstileMode', () => {
it('passes through known modes', () => {
expect(normalizeTurnstileMode('off')).toBe('off')
expect(normalizeTurnstileMode('shadow')).toBe('shadow')
expect(normalizeTurnstileMode('enforce')).toBe('enforce')
})
it('clamps unknown or missing values to off', () => {
expect(normalizeTurnstileMode('enfroce')).toBe('off')
expect(normalizeTurnstileMode('')).toBe('off')
expect(normalizeTurnstileMode(undefined)).toBe('off')
})
})
describe('isTurnstileEnabled', () => {
it('renders when the flag is active and a sitekey is configured', () => {
expect(isTurnstileEnabled('shadow', 'site-key')).toBe(true)
expect(isTurnstileEnabled('enforce', 'site-key')).toBe(true)
})
it('does not render when the flag is off', () => {
expect(isTurnstileEnabled('off', 'site-key')).toBe(false)
})
it('does not render without a sitekey (OSS / local builds)', () => {
expect(isTurnstileEnabled('shadow', '')).toBe(false)
expect(isTurnstileEnabled('enforce', '')).toBe(false)
})
})
describe('useTurnstile', () => {
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
mockedDevOverride.mockReturnValue(undefined)
mockedGetServerFeature.mockReturnValue('off')
mockedSiteKey.mockReturnValue('site-key')
})
describe('mode precedence', () => {
it('prefers the dev override over remote config and the server feature', () => {
mockedDevOverride.mockReturnValue('enforce')
remoteConfig.value = { signup_turnstile: 'shadow' }
mockedGetServerFeature.mockReturnValue('off')
expect(useTurnstile().mode.value).toBe('enforce')
})
it('uses remote config when there is no dev override', () => {
remoteConfig.value = { signup_turnstile: 'shadow' }
expect(useTurnstile().mode.value).toBe('shadow')
})
it('falls back to the server feature flag (default off) when nothing else is set', () => {
mockedGetServerFeature.mockReturnValue('enforce')
expect(useTurnstile().mode.value).toBe('enforce')
expect(mockedGetServerFeature).toHaveBeenCalledWith(
'signup_turnstile',
'off'
)
})
it('clamps an unknown remote-config value to off', () => {
remoteConfig.value = {
signup_turnstile: 'bogus' as unknown as 'shadow'
}
expect(useTurnstile().mode.value).toBe('off')
})
it('resolves to off when every source is unset', () => {
expect(useTurnstile().mode.value).toBe('off')
})
})
describe('enabled / enforced', () => {
it('is enabled but not enforced in shadow with a sitekey', () => {
remoteConfig.value = { signup_turnstile: 'shadow' }
const { enabled, enforced } = useTurnstile()
expect(enabled.value).toBe(true)
expect(enforced.value).toBe(false)
})
it('is enabled and enforced in enforce with a sitekey', () => {
remoteConfig.value = { signup_turnstile: 'enforce' }
const { enabled, enforced } = useTurnstile()
expect(enabled.value).toBe(true)
expect(enforced.value).toBe(true)
})
it('is neither enabled nor enforced without a sitekey, even in enforce', () => {
remoteConfig.value = { signup_turnstile: 'enforce' }
mockedSiteKey.mockReturnValue('')
const { enabled, enforced } = useTurnstile()
expect(enabled.value).toBe(false)
expect(enforced.value).toBe(false)
})
it('is disabled when the mode is off', () => {
const { enabled, enforced } = useTurnstile()
expect(enabled.value).toBe(false)
expect(enforced.value).toBe(false)
})
})
})

View File

@@ -0,0 +1,44 @@
import { computed } from 'vue'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { TurnstileMode } from '@/platform/remoteConfig/types'
/**
* Clamp an externally-sourced value to a known TurnstileMode. Unknown strings
* (typos, stale flag variants) resolve to 'off' so a bad value can never leave
* the widget rendered-but-unenforced — mirrors the server-side resolver.
*/
export function normalizeTurnstileMode(raw: string | undefined): TurnstileMode {
return raw === 'shadow' || raw === 'enforce' ? raw : 'off'
}
/**
* Whether the signup Turnstile widget should render. Purely config-driven: the
* flag must be shadow/enforce and a sitekey must be configured. OSS / local
* builds resolve no sitekey — the real per-env keys are tree-shaken out via the
* __DISTRIBUTION__ build define (see config/turnstile.ts) — so the widget never
* renders. The local-OSS exemption lives server-side (loopback-IP check in
* CreateCustomer).
*/
export function isTurnstileEnabled(
mode: TurnstileMode,
siteKey: string
): boolean {
return mode !== 'off' && siteKey !== ''
}
/**
* Reactive Turnstile state for the signup form.
* - `enabled`: render the widget
* - `enforced`: block submit until the challenge is solved
*/
export function useTurnstile() {
const { flags } = useFeatureFlags()
const mode = computed(() => normalizeTurnstileMode(flags.signupTurnstileMode))
const siteKey = computed(getTurnstileSiteKey)
const enabled = computed(() => isTurnstileEnabled(mode.value, siteKey.value))
const enforced = computed(() => enabled.value && mode.value === 'enforce')
return { mode, siteKey, enabled, enforced }
}

View File

@@ -7,7 +7,9 @@ import type {
CreateTopupResponse,
CurrentTeamCreditStop,
Plan,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse,
SubscriptionDuration,
SubscriptionTier,
@@ -21,9 +23,9 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601 */
/** ISO 8601; format at the display site. */
renewalDate: string | null
/** ISO 8601 */
/** ISO 8601; format at the display site. */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -43,16 +45,27 @@ export interface BillingActions {
fetchBalance: () => Promise<void>
subscribe: (
planSlug: string,
returnUrl?: string,
cancelUrl?: string
options?: SubscribeOptions
) => Promise<SubscribeResponse | void>
previewSubscribe: (
planSlug: string
planSlug: string,
options?: PreviewSubscribeOptions
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
/**
* Reactivates a cancelled-but-still-active subscription. Legacy has no
* dedicated endpoint, so the legacy adapter re-runs the checkout flow.
* The workspace adapter refreshes status and balance internally on success.
*/
resubscribe: () => Promise<void>
/** `amountCents` must be a whole-dollar multiple of 100. */
/**
* Purchases additional credits. Standardized on **whole-dollar cents**
* (multiples of 100); the legacy adapter divides by 100 for the
* dollar-based /customers/credit endpoint.
* Pass-through by design: the caller owns the completed/pending follow-up
* (balance refresh or billing-op polling), so this does not refresh.
*/
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
@@ -80,8 +93,11 @@ export interface BillingState {
isLoading: Ref<boolean>
error: Ref<string | null>
isActiveSubscription: ComputedRef<boolean>
/** Reflects the active workspace's tier, not the user's personal tier. */
isFreeTier: ComputedRef<boolean>
/** Coarse funding state (`billing_status`); legacy reports null. */
billingStatus: ComputedRef<BillingStatus | null>
/** Lifecycle state; legacy synthesizes it from active/cancelled flags. */
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>

View File

@@ -1,6 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type {
BillingStatusResponse,
Plan
@@ -20,12 +22,14 @@ const {
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
mockUpdateActiveWorkspace,
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
mockUpdateActiveWorkspace: vi.fn(),
mockBillingStatus: {
value: {
is_active: true,
@@ -44,15 +48,25 @@ vi.mock('@vueuse/core', async (importOriginal) => {
}
})
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
vi.mock('@/composables/useFeatureFlags', async () => {
const { ref } = await import('vue')
const teamWorkspacesEnabledRef = ref(mockTeamWorkspacesEnabled.value)
Object.defineProperty(mockTeamWorkspacesEnabled, 'value', {
get: () => teamWorkspacesEnabledRef.value,
set: (value: boolean) => {
teamWorkspacesEnabledRef.value = value
}
})
}))
return {
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
@@ -64,7 +78,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
? { id: 'personal-123', type: 'personal' }
: { id: 'team-456', type: 'team' }
},
updateActiveWorkspace: vi.fn()
updateActiveWorkspace: mockUpdateActiveWorkspace
})
}))
@@ -142,11 +156,28 @@ describe('useBillingContext', () => {
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
})
it('returns legacy type for personal workspace', () => {
it('selects legacy type when team workspaces are disabled', () => {
mockTeamWorkspacesEnabled.value = false
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team when team workspaces are enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
@@ -206,6 +237,14 @@ describe('useBillingContext', () => {
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('rejects topup amounts that are not positive whole-dollar cents', async () => {
const { topup } = useBillingContext()
await expect(topup(550)).rejects.toThrow()
await expect(topup(0)).rejects.toThrow()
await expect(topup(-100)).rejects.toThrow()
await expect(topup(99.5)).rejects.toThrow()
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)
@@ -221,6 +260,42 @@ describe('useBillingContext', () => {
expect(() => showSubscriptionDialog()).not.toThrow()
})
it('reinitializes workspace billing when the type flips on after legacy init', async () => {
mockTeamWorkspacesEnabled.value = false
mockIsPersonal.value = true
const { type, initialize } = useBillingContext()
await initialize()
await nextTick()
expect(type.value).toBe('legacy')
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
})
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
await initialize()
await nextTick()
expect(mockUpdateActiveWorkspace).toHaveBeenCalledWith({
isSubscribed: true,
subscriptionPlan: null
})
})
})
describe('getMaxSeats', () => {
it('returns 1 for personal workspaces regardless of tier', () => {
const { getMaxSeats } = useBillingContext()

View File

@@ -7,6 +7,10 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
PreviewSubscribeOptions,
SubscribeOptions
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type {
@@ -27,11 +31,11 @@ import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspa
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
/**
* Unified billing context that automatically switches between legacy (user-scoped)
* and workspace billing based on the active workspace type.
* Unified billing context that selects the billing implementation by build/flag.
*
* - Personal workspaces use legacy billing via /customers/* endpoints
* - Team workspaces use workspace billing via /billing/* endpoints
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for both
* personal (single-seat workspace) and team workspaces
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -92,16 +96,14 @@ function useBillingContextInternal(): BillingContext {
const error = ref<string | null>(null)
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
* Determines which billing type to use, keyed only on the build/flag:
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
* - Team workspaces feature enabled: workspace (/api/billing), for both
* personal (single-seat workspace) and team workspaces
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
})
const type = computed<BillingType>(() =>
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
)
const activeContext = computed(() =>
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
@@ -173,7 +175,7 @@ function useBillingContextInternal(): BillingContext {
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
if (!sub) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,
@@ -183,26 +185,28 @@ function useBillingContextInternal(): BillingContext {
{ immediate: true }
)
// Initialize billing when workspace changes
function resetBillingState() {
isInitialized.value = false
error.value = null
}
// type can flip after setup when the team-workspaces flag resolves from
// authenticated config, swapping the active backend; a fresh init is needed.
// The watch fires only when id or type actually changes, so any fire with a
// workspace selected warrants a reinit.
watch(
() => store.activeWorkspace?.id,
async (newWorkspaceId, oldWorkspaceId) => {
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
if (!newWorkspaceId) {
// No workspace selected - reset state
isInitialized.value = false
error.value = null
resetBillingState()
return
}
if (newWorkspaceId !== oldWorkspaceId) {
// Workspace changed - reinitialize
isInitialized.value = false
try {
await initialize()
} catch (err) {
// Error is already captured in error ref
console.error('Failed to initialize billing context:', err)
}
isInitialized.value = false
try {
await initialize()
} catch (err) {
console.error('Failed to initialize billing context:', err)
}
},
{ immediate: true }
@@ -233,16 +237,15 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.fetchBalance()
}
async function subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
) {
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
async function subscribe(planSlug: string, options?: SubscribeOptions) {
return activeContext.value.subscribe(planSlug, options)
}
async function previewSubscribe(planSlug: string) {
return activeContext.value.previewSubscribe(planSlug)
async function previewSubscribe(
planSlug: string,
options?: PreviewSubscribeOptions
) {
return activeContext.value.previewSubscribe(planSlug, options)
}
async function manageSubscription() {
@@ -258,6 +261,15 @@ function useBillingContextInternal(): BillingContext {
}
async function topup(amountCents: number) {
if (
!Number.isInteger(amountCents) ||
amountCents <= 0 ||
amountCents % 100 !== 0
) {
throw new Error(
'Top-up amount must be a positive whole-dollar cent value'
)
}
return activeContext.value.topup(amountCents)
}

View File

@@ -5,7 +5,9 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeOptions,
PreviewSubscribeResponse,
SubscribeOptions,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useAuthStore } from '@/stores/authStore'
@@ -147,15 +149,15 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function subscribe(
_planSlug: string,
_returnUrl?: string,
_cancelUrl?: string
_options?: SubscribeOptions
): Promise<SubscribeResponse | void> {
// Legacy billing uses Stripe checkout flow via useSubscription
await legacySubscribe()
}
async function previewSubscribe(
_planSlug: string
_planSlug: string,
_options?: PreviewSubscribeOptions
): Promise<PreviewSubscribeResponse | null> {
// Legacy billing doesn't support preview - returns null
return null

View File

@@ -8,7 +8,6 @@ import type {
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { useLitegraphService } from '@/services/litegraphService'
async function navigateToGraph(targetGraph: LGraph) {
const canvasStore = useCanvasStore()
@@ -49,23 +48,7 @@ export function useFocusNode() {
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
}
async function enterSubgraph(
nodeId: string,
executionIdMap?: Map<string, LGraphNode>
) {
if (!canvasStore.canvas) return
const graphNode = executionIdMap
? executionIdMap.get(nodeId)
: getNodeByExecutionId(app.rootGraph, nodeId)
if (!graphNode?.graph) return
await navigateToGraph(graphNode.graph as LGraph)
useLitegraphService().fitView()
}
return {
focusNode,
enterSubgraph
focusNode
}
}

View File

@@ -84,6 +84,7 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
removable?: boolean
values?: unknown
}
/** Input specification from node definition */
@@ -213,7 +214,8 @@ function extractWidgetDisplayOptions(
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
read_only: widget.options.read_only,
removable: widget.options.removable
}
}

View File

@@ -221,6 +221,39 @@ describe('useFeatureFlags', () => {
})
})
describe('signupTurnstileMode', () => {
afterEach(() => {
localStorage.clear()
})
it('falls back to the server feature flag with default off', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SIGNUP_TURNSTILE) return 'enforce'
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.signupTurnstileMode).toBe('enforce')
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SIGNUP_TURNSTILE,
'off'
)
})
it('lets a dev override beat the server value', () => {
vi.mocked(api.getServerFeature).mockReturnValue('off')
localStorage.setItem(
`ff:${ServerFeatureFlag.SIGNUP_TURNSTILE}`,
'"shadow"'
)
const { flags } = useFeatureFlags()
expect(flags.signupTurnstileMode).toBe('shadow')
})
})
describe('unifiedCloudAuthEnabled', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -29,7 +29,8 @@ export enum ServerFeatureFlag {
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth'
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
SIGNUP_TURNSTILE = 'signup_turnstile'
}
/**
@@ -173,6 +174,13 @@ export function useFeatureFlags() {
remoteConfig.value.unified_cloud_auth,
false
)
},
get signupTurnstileMode() {
return resolveFlag(
ServerFeatureFlag.SIGNUP_TURNSTILE,
remoteConfig.value.signup_turnstile,
'off'
)
}
})

View File

@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useUrlActionLoaders } from './useUrlActionLoaders'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockFlags = vi.hoisted(() => ({ value: { teamWorkspacesEnabled: true } }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFlags.value })
}))
const mocks = vi.hoisted(() => ({
loadInvite: vi.fn().mockResolvedValue(undefined),
loadCreateWorkspace: vi.fn().mockResolvedValue(undefined),
loadPricingTable: vi.fn().mockResolvedValue(undefined),
useInvite: vi.fn(),
useCreateWorkspace: vi.fn(),
usePricingTable: vi.fn()
}))
mocks.useInvite.mockImplementation(() => ({
loadInviteFromUrl: mocks.loadInvite
}))
mocks.useCreateWorkspace.mockImplementation(() => ({
loadCreateWorkspaceFromUrl: mocks.loadCreateWorkspace
}))
mocks.usePricingTable.mockImplementation(() => ({
loadPricingTableFromUrl: mocks.loadPricingTable
}))
vi.mock('@/platform/workspace/composables/useInviteUrlLoader', () => ({
useInviteUrlLoader: mocks.useInvite
}))
vi.mock('@/platform/workspace/composables/useCreateWorkspaceUrlLoader', () => ({
useCreateWorkspaceUrlLoader: mocks.useCreateWorkspace
}))
vi.mock(
'@/platform/cloud/subscription/composables/usePricingTableUrlLoader',
() => ({ usePricingTableUrlLoader: mocks.usePricingTable })
)
describe('useUrlActionLoaders', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockFlags.value = { teamWorkspacesEnabled: true }
})
it('does not instantiate or run any loader off cloud', async () => {
mockIsCloud.value = false
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.useInvite).not.toHaveBeenCalled()
expect(mocks.useCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.usePricingTable).not.toHaveBeenCalled()
expect(mocks.loadInvite).not.toHaveBeenCalled()
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.loadPricingTable).not.toHaveBeenCalled()
})
it('runs all loaders on cloud when team workspaces are enabled', async () => {
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.loadInvite).toHaveBeenCalledOnce()
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
})
it('runs the pricing loader but skips the flag-gated loaders when team workspaces are disabled', async () => {
mockFlags.value = { teamWorkspacesEnabled: false }
const { runUrlActionLoaders } = useUrlActionLoaders()
await runUrlActionLoaders()
expect(mocks.loadInvite).not.toHaveBeenCalled()
expect(mocks.loadCreateWorkspace).not.toHaveBeenCalled()
expect(mocks.loadPricingTable).toHaveBeenCalledOnce()
})
it('isolates a pricing-loader failure so it does not abort the boot chain', async () => {
mocks.loadPricingTable.mockRejectedValueOnce(new Error('boom'))
const { runUrlActionLoaders } = useUrlActionLoaders()
await expect(runUrlActionLoaders()).resolves.toBeUndefined()
expect(mocks.loadInvite).toHaveBeenCalledOnce()
expect(mocks.loadCreateWorkspace).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,55 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePricingTableUrlLoader } from '@/platform/cloud/subscription/composables/usePricingTableUrlLoader'
import { isCloud } from '@/platform/distribution/types'
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
/**
* Aggregates the query-param "deep link" loaders the cloud app checks on mount
* (`?invite`, `?create_workspace`, `?pricing`). The loaders are instantiated in
* setup so their `useRoute`/`useRouter` resolve; call `runUrlActionLoaders()`
* from `onMounted` once the app is ready.
*/
export function useUrlActionLoaders() {
const { flags } = useFeatureFlags()
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
const createWorkspaceUrlLoader = isCloud
? useCreateWorkspaceUrlLoader()
: null
const pricingTableUrlLoader = isCloud ? usePricingTableUrlLoader() : null
async function runUrlActionLoaders() {
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN).
// WorkspaceAuthGate ensures flag state is resolved before the app mounts.
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
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(
'[UrlActionLoaders] Failed to load create workspace from URL:',
error
)
}
}
// Open the pricing table from URL if present (e.g., ?pricing=1 / ?pricing=team).
// Not gated on the team-workspaces flag: it also drives personal/legacy users.
if (pricingTableUrlLoader) {
try {
await pricingTableUrlLoader.loadPricingTableFromUrl()
} catch (error) {
console.error(
'[UrlActionLoaders] Failed to load pricing table from URL:',
error
)
}
}
}
return { runUrlActionLoaders }
}

View File

@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
const executionStore = useExecutionStore()
watch(
() => workflowStore.activeWorkflow,
(workflow) => {
if (
workflow &&
executionStore.getWorkflowStatus(workflow) !== 'running'
) {
() => {
const workflow = workflowStore.activeWorkflow
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
},
([workflow, status]) => {
if (workflow && status !== undefined && status !== 'running') {
executionStore.clearWorkflowStatus(workflow)
}
},

View File

@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getTurnstileSiteKey } from '@/config/turnstile'
const TURNSTILE_TEST_SITE_KEY = '1x00000000000000000000AA'
// __USE_PROD_CONFIG__ is false under vitest (see vitest.setup.ts), so the
// build-time fallback resolves to the staging sitekey.
const STAGING_TURNSTILE_SITE_KEY = '0x4AAAAAADnYY4_Q0qxHZ5a7'
// Mutable containers go through vi.hoisted so the hoisted vi.mock factories can
// reference them without a temporal-dead-zone crash (which surfaces under
// coverage instrumentation, not a plain run).
const { mockRemoteConfig } = vi.hoisted(() => ({
mockRemoteConfig: { value: {} as Record<string, unknown> }
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig,
configValueOrDefault: (
cfg: Record<string, unknown>,
key: string,
fallback: unknown
) => cfg[key] || fallback
}))
describe('getTurnstileSiteKey', () => {
beforeEach(() => {
mockRemoteConfig.value = {}
vi.stubGlobal('__DISTRIBUTION__', 'localhost')
})
afterEach(() => {
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
describe('OSS / non-cloud build', () => {
it('falls back to the always-pass test key in dev', () => {
vi.stubEnv('DEV', true)
expect(getTurnstileSiteKey()).toBe(TURNSTILE_TEST_SITE_KEY)
})
it('returns empty string outside dev so the widget never renders', () => {
vi.stubEnv('DEV', false)
expect(getTurnstileSiteKey()).toBe('')
})
it('ignores remote config (the widget is cloud-only)', () => {
vi.stubEnv('DEV', false)
mockRemoteConfig.value = { turnstile_sitekey: '0xshould-not-be-used' }
expect(getTurnstileSiteKey()).toBe('')
})
})
describe('cloud build', () => {
beforeEach(() => {
vi.stubGlobal('__DISTRIBUTION__', 'cloud')
})
it('returns the sitekey delivered via remote config', () => {
mockRemoteConfig.value = { turnstile_sitekey: '0x4AAAAAreal' }
expect(getTurnstileSiteKey()).toBe('0x4AAAAAreal')
})
it('falls back to the build-time per-env sitekey during a remote-config gap', () => {
expect(getTurnstileSiteKey()).toBe(STAGING_TURNSTILE_SITE_KEY)
})
})
})

43
src/config/turnstile.ts Normal file
View File

@@ -0,0 +1,43 @@
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
/**
* Cloudflare Turnstile always-pass test sitekey, used only in local dev so the
* signup flow can be exercised without a real key.
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
*/
const TURNSTILE_TEST_SITE_KEY = '1x00000000000000000000AA'
// Public per-environment sitekeys, baked at build time so a cloud build renders
// the widget even before (or without) remote config; remote config still
// overrides them, so keys rotate live without a rebuild.
const PROD_TURNSTILE_SITE_KEY = '0x4AAAAAADnYZPVOpFCL_zeo'
const STAGING_TURNSTILE_SITE_KEY = '0x4AAAAAADnYY4_Q0qxHZ5a7'
/**
* Returns the Cloudflare Turnstile sitekey for the current environment.
* - OSS / localhost never renders the cloud widget (server-side loopback
* exemption covers local signup); in dev it falls back to the always-pass test
* key so the flow is exercisable locally, otherwise ''.
* - Cloud builds prefer the per-env sitekey delivered via remote config
* (`turnstile_sitekey`) and fall back to the build-time constant, so the widget
* still renders during a remote-config gap rather than silently disappearing.
*/
export function getTurnstileSiteKey(): string {
// Gate on the __DISTRIBUTION__ build define rather than the cross-module
// `isCloud` const so dead-code elimination strips the real per-env sitekeys
// from OSS/desktop bundles — same idiom as initTelemetry.ts, enforced by the
// dist scan in ci-dist-telemetry-scan.yaml.
const isCloudBuild = __DISTRIBUTION__ === 'cloud'
if (!isCloudBuild) {
return import.meta.env.DEV ? TURNSTILE_TEST_SITE_KEY : ''
}
return configValueOrDefault(
remoteConfig.value,
'turnstile_sitekey',
__USE_PROD_CONFIG__ ? PROD_TURNSTILE_SITE_KEY : STAGING_TURNSTILE_SITE_KEY
)
}

View File

@@ -1,5 +1,9 @@
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
import {
zAutogrowOptions,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -8,6 +12,7 @@ const dynamicTypeResolvers: Record<
(inputSpec: InputSpecV2) => string[]
> = {
COMFY_AUTOGROW_V3: resolveAutogrowType,
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
COMFY_MATCHTYPE_V3: (input) =>
zMatchTypeOptions
.safeParse(input)
@@ -20,6 +25,21 @@ export function resolveInputType(input: InputSpecV2): string[] {
: input.type.split(',')
}
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
const template = parsed.data?.[1]?.template
if (!template) return []
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
template.required,
template.optional
]
return inputTypes.flatMap((inputType) =>
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
resolveInputType(transformInputSpecV1ToV2(v, { name }))
)
)
}
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}

View File

@@ -1,7 +1,9 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -47,6 +49,22 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
)
}
function addDynamicGroup(
node: LGraphNode,
template: object,
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
) {
const options: Record<string, unknown> = { template }
if (min !== undefined) options.min = min
if (max !== undefined) options.max = max
addNodeInput(
node,
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
name,
isOptional: false
})
)
}
function addAutogrow(node: LGraphNode, template: unknown) {
addNodeInput(
node,
@@ -287,3 +305,101 @@ describe('Autogrow', () => {
])
})
})
describe('Dynamic Groups', () => {
const stringTemplate = { required: { a: ['STRING', {}] } }
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
const widgetNamed = (node: LGraphNode, name: string) =>
node.widgets!.find((w) => w.name === name)!
test('renders min rows on creation', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
expect(widgetNames(node)).toStrictEqual([
'g',
'g.__row__0',
'g.0.a',
'g.__row__1',
'g.1.a'
])
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('add row appends a new row up to max', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
expect(widgetNames(node)).toStrictEqual(['g'])
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a'])
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// At max, further adds are ignored.
widgetNamed(node, 'g').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('remove row renumbers later rows', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
const row0Field = widgetNamed(node, 'g.0.a')
const row2Field = widgetNamed(node, 'g.2.a')
widgetNamed(node, 'g.__row__1').callback?.(undefined)
expect(widgetNames(node)).toStrictEqual([
'g',
'g.__row__0',
'g.0.a',
'g.__row__1',
'g.1.a'
])
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// Row 0 is untouched; the former row 2 shifts down into row 1.
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
})
test('rows below min are not removable', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
// Attempting to remove a protected row is a no-op.
widgetNamed(node, 'g.__row__0').callback?.(undefined)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
})
test('canvas click removes a row only on the remove hit target', () => {
const node = testNode()
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
widgetNamed(node, 'g').callback?.(undefined)
widgetNamed(node, 'g').callback?.(undefined)
const header = widgetNamed(node, 'g.__row__1')
const up = { type: 'pointerup' } as CanvasPointerEvent
const down = { type: 'pointerdown' } as CanvasPointerEvent
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
// Releasing away from the remove target does nothing.
header.mouse?.(up, [0, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// A pointerdown on the target does nothing (only release acts).
header.mouse?.(down, [xCenter, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
// Releasing on the target removes the row.
header.mouse?.(up, [xCenter, 0] as Point, node)
expect(inputNames(node)).toStrictEqual(['g.0.a'])
})
})

View File

@@ -2,10 +2,12 @@ import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { t } from '@/i18n'
import type {
ISlotType,
INodeInputSlot,
INodeOutputSlot
INodeOutputSlot,
Point
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -13,11 +15,14 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
zAutogrowOptions,
zDynamicComboInputSpec,
zDynamicGroupInputSpec,
zMatchTypeOptions
} from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -28,6 +33,15 @@ import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
type DynamicGroupState = {
min: number
max: number
inputSpecs: InputSpecV2[]
}
type DynamicGroupNode = LGraphNode & {
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
}
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
@@ -77,6 +91,7 @@ function dynamicComboWidget(
widgetName?: string
) {
const { addNodeInput } = useLitegraphService()
const { deleteWidget } = useWidgetValueStore()
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
const inputData = parseResult.data
@@ -99,7 +114,10 @@ function dynamicComboWidget(
const newSpec = value ? options[value] : undefined
const removedInputs = remove(node.inputs, isInGroup)
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
for (const widget of remove(node.widgets, isInGroup)) {
widget.onRemove?.()
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) return
@@ -210,7 +228,321 @@ function dynamicComboWidget(
return { widget, minWidth, minHeight }
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
function withComfyDynamicGroup(
node: LGraphNode
): asserts node is DynamicGroupNode {
if (node.comfyDynamic?.dynamicGroup) return
node.comfyDynamic ??= {}
node.comfyDynamic.dynamicGroup = {}
}
const ROW_MARKER = '__row__'
const rowHeaderName = (group: string, row: number) =>
`${group}.${ROW_MARKER}${row}`
const fieldName = (group: string, row: number, field: string) =>
`${group}.${row}.${field}`
/** Extract the row index from a header widget name, or `undefined`. */
function headerRowIndex(group: string, name: string): number | undefined {
const prefix = `${group}.${ROW_MARKER}`
if (!name.startsWith(prefix)) return undefined
const row = Number(name.slice(prefix.length))
return Number.isInteger(row) ? row : undefined
}
/** Rename a field that sits above the removed row, shifting its index down. */
function shiftedFieldName(
group: string,
name: string,
removedRow: number
): string | undefined {
const prefix = `${group}.`
if (!name.startsWith(prefix)) return undefined
const rest = name.slice(prefix.length)
const dot = rest.indexOf('.')
if (dot === -1) return undefined
const row = Number(rest.slice(0, dot))
if (!Number.isInteger(row) || row <= removedRow) return undefined
return fieldName(group, row - 1, rest.slice(dot + 1))
}
const belongsToRow = (group: string, name: string, row: number): boolean =>
name === rowHeaderName(group, row) || name.startsWith(`${group}.${row}.`)
const CANVAS_MARGIN = 15
/** Draw the "Add row" capsule button on the LiteGraph canvas. */
function drawGroupButton(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
label: string,
disabled: boolean
): void {
const height = LiteGraph.NODE_WIDGET_HEIGHT
ctx.save()
if (disabled) ctx.globalAlpha *= 0.5
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
ctx.beginPath()
ctx.roundRect(CANVAS_MARGIN, y, width - CANVAS_MARGIN * 2, height, [
height * 0.5
])
ctx.fill()
if (!disabled) ctx.stroke()
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
ctx.textAlign = 'center'
ctx.fillText(label, width * 0.5, y + height * 0.7)
ctx.restore()
}
/** Horizontal centre of a row header's remove (✕) hit target. */
const removeButtonCenterX = (width: number) =>
width - CANVAS_MARGIN - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
/** Draw a row header (label on the left, ✕ on the right) on the canvas. */
function drawGroupRowHeader(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
label: string,
removable: boolean
): void {
const height = LiteGraph.NODE_WIDGET_HEIGHT
ctx.save()
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
ctx.textAlign = 'left'
ctx.fillText(label, CANVAS_MARGIN, y + height * 0.7)
if (removable) {
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
ctx.textAlign = 'center'
ctx.fillText('\u2715', removeButtonCenterX(width), y + height * 0.7)
}
ctx.restore()
}
const countGroupRows = (group: string, node: LGraphNode): number =>
(node.widgets ?? []).reduce(
(count, w) =>
headerRowIndex(group, w.name) !== undefined ? count + 1 : count,
0
)
/** Build a row's header + field widgets, returning them detached from the node. */
function createRow(
group: string,
row: number,
state: DynamicGroupState,
node: DynamicGroupNode
): IBaseWidget[] {
const { addNodeInput } = useLitegraphService()
const startLen = node.widgets!.length
const header = node.addCustomWidget({
name: rowHeaderName(group, row),
type: 'dynamic_group_row',
value: row,
y: 0,
serialize: false,
callback: undefined as IBaseWidget['callback'],
draw(
this: IBaseWidget,
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
width: number,
y: number
) {
const idx = headerRowIndex(group, this.name) ?? 0
const label = t('dynamicGroup.row', { index: idx + 1 })
drawGroupRowHeader(ctx, width, y, label, !!this.options?.removable)
},
mouse(this: IBaseWidget, event: CanvasPointerEvent, pos: Point) {
if (event.type !== 'pointerup' || !this.options?.removable) return false
const half = LiteGraph.NODE_WIDGET_HEIGHT * 0.5
if (Math.abs(pos[0] - removeButtonCenterX(node.size[0])) > half)
return false
const idx = headerRowIndex(group, this.name)
if (idx !== undefined) removeRow(group, idx, node)
return true
},
options: { serialize: false, socketless: true, removable: row >= state.min }
})
header.callback = function (this: IBaseWidget) {
const idx = headerRowIndex(group, this.name)
if (idx !== undefined) removeRow(group, idx, node)
}
for (const spec of state.inputSpecs)
addNodeInput(node, {
...spec,
name: fieldName(group, row, spec.name),
display_name: spec.display_name ?? spec.name
})
return node.widgets!.splice(startLen)
}
function insertRowAfterGroup(
group: string,
node: LGraphNode,
rowWidgets: IBaseWidget[]
): void {
const lastIdx = node.widgets!.findLastIndex(
(w) => w.name === group || w.name.startsWith(`${group}.`)
)
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
}
function syncController(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
const controller = node.widgets?.find((w) => w.name === group)
if (!state || !controller) return
controller.options ??= {}
controller.options.disabled = countGroupRows(group, node) >= state.max
node.size[1] = node.computeSize([...node.size])[1]
}
function addRow(group: string, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const row = countGroupRows(group, node)
if (row >= state.max) return
insertRowAfterGroup(group, node, createRow(group, row, state, node))
syncController(group, node)
app.canvas?.setDirty(true, true)
}
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state || row < state.min) return
for (const w of remove(node.widgets!, (w) =>
belongsToRow(group, w.name, row)
))
w.onRemove?.()
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
for (const w of node.widgets ?? []) {
const headerRow = headerRowIndex(group, w.name)
if (headerRow !== undefined && headerRow > row) {
w.name = rowHeaderName(group, headerRow - 1)
w.options ??= {}
w.options.removable = headerRow - 1 >= state.min
continue
}
const shifted = shiftedFieldName(group, w.name, row)
if (shifted !== undefined) w.name = shifted
}
for (const inp of node.inputs) {
const shifted = shiftedFieldName(group, inp.name, row)
if (shifted === undefined) continue
inp.name = shifted
if (inp.widget) inp.widget.name = shifted
}
syncController(group, node)
app.canvas?.setDirty(true, true)
}
/** Rebuild the group from scratch to hold exactly `count` rows. */
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
const state = node.comfyDynamic.dynamicGroup[group]
if (!state) return
node.widgets ??= []
const isRowMember = (name: string) => name.startsWith(`${group}.`)
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
w.onRemove?.()
remove(node.inputs, (inp) => isRowMember(inp.name))
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
const rowWidgets: IBaseWidget[] = []
for (let row = 0; row < count; row++)
rowWidgets.push(...createRow(group, row, state, node))
node.widgets.splice(insertAt, 0, ...rowWidgets)
}
function dynamicGroupWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
_appArg: ComfyApp
) {
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
const [, { template, min, max }] = parseResult.data
const toSpecs = (
inputs: Record<string, InputSpec> | undefined,
isOptional: boolean
) =>
Object.entries(inputs ?? {}).map(([name, spec]) =>
transformInputSpecV1ToV2(spec, { name, isOptional })
)
const inputSpecs = [
...toSpecs(template.required, false),
...toSpecs(template.optional, true)
]
withComfyDynamicGroup(node)
const typedNode = node as DynamicGroupNode
typedNode.comfyDynamic.dynamicGroup[inputName] = { min, max, inputSpecs }
node.widgets ??= []
const controller = node.addCustomWidget({
name: inputName,
type: 'dynamic_group_add',
value: min,
y: 0,
serialize: true,
callback: () => addRow(inputName, typedNode),
draw(
this: IBaseWidget,
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
width: number,
y: number
) {
drawGroupButton(
ctx,
width,
y,
t('dynamicGroup.addRow'),
!!this.options?.disabled
)
},
mouse(this: IBaseWidget, event: CanvasPointerEvent) {
if (event.type !== 'pointerup' || this.options?.disabled) return false
addRow(inputName, typedNode)
return true
},
options: { serialize: false, socketless: true, disabled: false }
})
Object.defineProperty(controller, 'value', {
get() {
return countGroupRows(inputName, typedNode)
},
set(count: unknown) {
if (typeof count !== 'number') return
rebuildRows(inputName, count, typedNode)
syncController(inputName, typedNode)
},
configurable: true
})
controller.value = min
return { widget: controller }
}
export const dynamicWidgets = {
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
}
const dynamicInputs: Record<
string,
(node: LGraphNode, inputSpec: InputSpecV2) => void

View File

@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
}))
vi.mock('@/extensions/core/load3d', () => ({}))
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
vi.mock('@/extensions/core/saveMesh', () => ({}))

View File

@@ -246,3 +246,37 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
expect(mockFetchApi).not.toHaveBeenCalled()
})
})
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
async function loadAudioUIWidget() {
vi.resetModules()
mockRegisterExtension.mockClear()
await import('./uploadAudio')
const extension = mockRegisterExtension.mock.calls
.map(([extension]) => extension as ComfyExtension)
.find((extension) => extension.name === 'Comfy.AudioWidget')
if (!extension)
throw new Error('Comfy.AudioWidget extension was not registered')
const widgets = await extension.getCustomWidgets!(fromAny({}))
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
}
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
it('excludes the audio player from workflow and prompt serialization', async () => {
const AUDIO_UI = await loadAudioUIWidget()
const domWidget = {
serialize: true,
options: {} as Record<string, unknown>
}
const node = fromAny<LGraphNode, unknown>({
addDOMWidget: vi.fn(() => domWidget),
constructor: { nodeData: { output_node: false } }
})
AUDIO_UI(node, 'audioUI')
expect(domWidget.serialize).toBe(false)
expect(domWidget.options.serialize).toBe(false)
})
})

View File

@@ -128,6 +128,7 @@ app.registerExtension({
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
audioUIWidget.options.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
@@ -529,6 +529,52 @@ describe('Subgraph Definition Garbage Collection', () => {
})
})
describe('beforeChange deprecated onBeforeChange shim', () => {
beforeEach(() => {
LiteGraph.onDeprecationWarning = []
LiteGraph.alwaysRepeatWarnings = true
})
afterEach(() => {
LiteGraph.alwaysRepeatWarnings = false
})
it('still invokes a listener assigned to onBeforeChange', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const onBeforeChange = vi.fn()
graph.onBeforeChange = onBeforeChange
graph.beforeChange(node)
expect(onBeforeChange).toHaveBeenCalledWith(graph, node)
})
it('warns that onBeforeChange is deprecated when used', () => {
const graph = new LGraph()
const deprecationCallback = vi.fn()
LiteGraph.onDeprecationWarning = [deprecationCallback]
graph.onBeforeChange = vi.fn()
graph.beforeChange()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('LGraph.onBeforeChange is deprecated'),
undefined
)
})
it('does not warn when no listener is assigned', () => {
const graph = new LGraph()
const deprecationCallback = vi.fn()
LiteGraph.onDeprecationWarning = [deprecationCallback]
graph.beforeChange()
expect(deprecationCallback).not.toHaveBeenCalled()
})
})
describe('Legacy LGraph Compatibility Layer', () => {
test('can be extended via prototype', ({ expect, minimalGraph }) => {
// @ts-expect-error Should always be an error.

View File

@@ -38,7 +38,6 @@ import type {
DefaultConnectionColors,
Dictionary,
HasBoundingRect,
IContextMenuValue,
INodeInputSlot,
INodeOutputSlot,
LinkNetwork,
@@ -56,6 +55,7 @@ import {
createBounds,
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -331,16 +331,16 @@ export class LGraph
onNodeAdded?(node: LGraphNode): void
onNodeRemoved?(node: LGraphNode): void
onTrigger?: LGraphTriggerHandler
/**
* @deprecated Assign a listener to {@link LGraphCanvas.onBeforeChange} instead.
* This graph-level hook will be removed in a future version.
*/
onBeforeChange?(graph: LGraph, info?: LGraphNode): void
onAfterChange?(graph: LGraph, info?: LGraphNode | null): void
onConnectionChange?(node: LGraphNode): void
on_change?(graph: LGraph): void
onSerialize?(data: ISerialisedGraph | SerialisableGraph): void
onConfigure?(data: ISerialisedGraph | SerialisableGraph): void
onGetNodeMenuOptions?(
options: (IContextMenuValue<unknown> | null)[],
node: LGraphNode
): void
// @ts-expect-error - Private property type needs fixing
private _input_nodes?: LGraphNode[]
@@ -1357,7 +1357,12 @@ export class LGraph
// used for undo, called before any change is made to the graph
beforeChange(info?: LGraphNode): void {
this.onBeforeChange?.(this, info)
if (this.onBeforeChange) {
warnDeprecated(
'LGraph.onBeforeChange is deprecated and will be removed in a future version. Assign a listener to LGraphCanvas.onBeforeChange instead.'
)
this.onBeforeChange(this, info)
}
this.canvasAction((c) => c.onBeforeChange?.(this))
}

View File

@@ -8642,8 +8642,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
callback: LGraphCanvas.onMenuNodeRemove
})
node.graph?.onGetNodeMenuOptions?.(options, node)
return options
}

View File

@@ -71,6 +71,7 @@ export interface IWidgetOptions<TValues = unknown> {
// Vue widget options
disabled?: boolean
removable?: boolean
useGrouping?: boolean
placeholder?: string
showThumbnails?: boolean

View File

@@ -92,6 +92,7 @@
"errorUserTokenAccessDenied": "رمز API الخاص بك لا يملك صلاحية الوصول إلى هذا المورد. يرجى التحقق من أذونات الرمز.",
"errorUserTokenInvalid": "رمز API المخزن غير صالح أو منتهي الصلاحية. يرجى تحديث الرمز في الإعدادات.",
"failedToCreateNode": "فشل إنشاء العقدة. يرجى المحاولة مرة أخرى أو التحقق من وحدة التحكم للحصول على التفاصيل.",
"failedToSetModelValue": "تمت إضافة العقدة، لكن لم يتم تعيين النموذج تلقائيًا. تحقق من وحدة التحكم لمزيد من التفاصيل.",
"fileFormats": "تنسيقات الملفات",
"fileName": "اسم الملف",
"fileSize": "حجم الملف",
@@ -241,7 +242,8 @@
"auth/user-not-found": "لم يتم العثور على حساب بهذا البريد الإلكتروني. هل ترغب في إنشاء حساب جديد؟",
"auth/weak-password": "كلمة المرور ضعيفة جداً. يرجى استخدام كلمة مرور أقوى تحتوي على 6 أحرف على الأقل.",
"auth/wrong-password": "كلمة المرور التي أدخلتها غير صحيحة. يرجى المحاولة مرة أخرى.",
"generic": "حدث خطأ أثناء تسجيل الدخول. يرجى المحاولة مرة أخرى."
"generic": "حدث خطأ أثناء تسجيل الدخول. يرجى المحاولة مرة أخرى.",
"signupBlocked": "تعذر إنشاء حسابك الآن. يرجى المحاولة لاحقًا. إذا استمرت المشكلة، راسل support@comfy.org."
},
"login": {
"andText": "و",
@@ -323,6 +325,11 @@
"signUpWithGithub": "إنشاء حساب باستخدام Github",
"signUpWithGoogle": "إنشاء حساب باستخدام Google",
"title": "إنشاء حساب"
},
"turnstile": {
"expired": "انتهت صلاحية التحقق. يرجى إكمال التحقق مرة أخرى.",
"failed": "فشل التحقق. يرجى المحاولة مرة أخرى.",
"submitBlockedHint": "يرجى إكمال التحقق أعلاه لتفعيل التسجيل."
}
},
"batch": {
@@ -346,6 +353,17 @@
"x": "س",
"y": "ص"
},
"boundingBoxes": {
"clearAll": "مسح الكل",
"clickRegionToEdit": "انقر على منطقة لتعديلها.",
"colors": "لوحة الألوان",
"descLabel": "الوصف",
"descPlaceholder": "وصف هذه المنطقة",
"textLabel": "نص",
"textPlaceholder": "النص المراد عرضه (كما هو)",
"typeObj": "كائن",
"typeText": "نص"
},
"breadcrumbsMenu": {
"app": "التطبيق",
"blueprint": "المخطط",
@@ -755,6 +773,13 @@
"creditsAvailable": "الرصيد المتاح",
"details": "التفاصيل",
"eventType": "نوع الحدث",
"eventTypes": {
"accountCreated": "تم إنشاء الحساب",
"apiNodeUsage": "استخدام عقدة الشريك",
"apiUsage": "استخدام API",
"creditAdded": "تمت إضافة أرصدة",
"gpuUsage": "استخدام GPU"
},
"faqs": "الأسئلة المتكررة",
"invoiceHistory": "تاريخ الفواتير",
"lastUpdated": "آخر تحديث",
@@ -811,6 +836,7 @@
},
"dataTypes": {
"*": "*",
"ARRAY": "مصفوفة",
"AUDIO": "صوت",
"AUDIO_ENCODER": "مُشَفِّر الصوت",
"AUDIO_ENCODER_OUTPUT": "مخرجات مُشَفِّر الصوت",
@@ -818,11 +844,13 @@
"BACKGROUND_REMOVAL": "إزالة الخلفية",
"BOOLEAN": "منطقي",
"BOUNDING_BOX": "مربع التحديد",
"BOUNDING_BOXES": "مربعات التحديد",
"CAMERA_CONTROL": "تحكم الكاميرا",
"CLIP": "CLIP",
"CLIP_VISION": "رؤية CLIP",
"CLIP_VISION_OUTPUT": "خرج رؤية CLIP",
"COLOR": "لون",
"COLORS": "ألوان",
"COMBO": "تركيب",
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
@@ -832,6 +860,7 @@
"CURVE": "منحنى",
"DA3_GEOMETRY": "DA3_GEOMETRY",
"DA3_MODEL": "DA3_MODEL",
"DICT": "قاموس",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "نموذج كشف الوجه",
"FACE_LANDMARKS": "معالم الوجه",
@@ -1830,6 +1859,35 @@
"zoomOptions": "خيارات التكبير",
"zoomOut": "تصغير"
},
"hdrViewer": {
"channel": "القناة",
"channels": {
"a": "ألفا",
"b": "B",
"g": "G",
"luminance": "السطوع",
"r": "R",
"rgb": "RGB"
},
"clipWarnings": "تحذيرات القص",
"dither": "تنعيم",
"exposure": "التعريض",
"failedToLoad": "فشل في تحميل صورة HDR",
"fitView": "ملاءمة",
"hdrImage": "صورة HDR",
"histogram": "مخطط بياني",
"inf": "لانهاية",
"max": "الحد الأقصى",
"mean": "المتوسط",
"min": "الحد الأدنى",
"nan": "غير رقم",
"normalizeExposure": "التعريض التلقائي",
"openInHdrViewer": "افتح في عارض HDR",
"resolution": "الدقة",
"sourceGamut": "مجال الألوان الأصلي",
"stdDev": "الانحراف المعياري",
"title": "عارض HDR"
},
"help": {
"helpCenterMenu": "قائمة مركز المساعدة",
"recentReleases": "الإصدارات الأخيرة"
@@ -2957,6 +3015,10 @@
"uploadError": "فشل في رفع صورة الرسام: {status} - {statusText}",
"width": "العرض"
},
"palette": {
"addColor": "إضافة لون",
"swatchTitle": "انقر للتعديل · اسحب لإعادة الترتيب · انقر بزر الفأرة الأيمن للإزالة"
},
"progressToast": {
"allDownloadsCompleted": "اكتملت جميع التنزيلات",
"downloadingModel": "جاري تنزيل النموذج...",
@@ -3041,7 +3103,6 @@
"color": "لون العقدة",
"editSubgraph": "تعديل الرسم البياني الفرعي",
"editTitle": "تعديل العنوان",
"enterSubgraph": "دخول الرسم الفرعي",
"errorHelp": "للمزيد من المساعدة، {github} أو {support}",
"errorHelpGithub": "إرسال مشكلة على GitHub",
"errorHelpSupport": "تواصل مع الدعم الفني",
@@ -3661,6 +3722,10 @@
"addApiCredits": "إضافة رصيد API",
"addCredits": "إضافة رصيد",
"addCreditsLabel": "أضف المزيد من الرصيد في أي وقت",
"additionalCredits": "رصيد إضافي",
"additionalCreditsInUse": "قيد الاستخدام",
"additionalCreditsInfo": "حول الرصيد الإضافي",
"additionalCreditsTooltip": "الرصيد الذي تضيفه فوق خطتك. يُستخدم بعد نفاد الرصيد الشهري. كل رصيد ينتهي بعد سنة من الشراء.",
"benefits": {
"benefit1": "رصيد شهري للعقد الشريكة - تجديد عند الحاجة",
"benefit1FreeTier": "رصيد شهري أكثر، مع إمكانية الشحن في أي وقت",
@@ -3682,27 +3747,55 @@
"keepSubscription": "الاحتفاظ بالاشتراك",
"title": "إلغاء الاشتراك"
},
"cancelSubscription": "إلغاء الاشتراك",
"cancelPlan": "إلغاء الخطة",
"cancelSuccess": "تم إلغاء الاشتراك بنجاح",
"canceled": "تم الإلغاء",
"canceledCard": {
"description": "لن يتم خصم أي رسوم أخرى منك. ستبقى ميزاتك نشطة حتى {date}.",
"title": "تم إلغاء اشتراكك"
},
"changePlan": "تغيير الخطة",
"changeTo": "تغيير إلى {plan}",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "شعار Comfy Cloud",
"contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك",
"contactUs": "تواصل معنا",
"creditSliderSave": "وفر {percent}% ({amount})",
"creditsLeftOfTotal": "{remaining} متبقية من أصل {total}",
"creditsRemainingThisMonth": "الرصيد المتبقي لهذا الشهر",
"creditsRemainingThisYear": "الرصيد المتبقي لهذا العام",
"creditsUsed": "{used} مستخدمة",
"creditsYouveAdded": "الرصيد الذي أضفته",
"currentPlan": "الخطة الحالية",
"customLoRAsLabel": "استيراد LoRAs الخاصة بك",
"description": "اختر الخطة الأنسب لك",
"descriptionWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
"downgrade": {
"body": "سيتم إزالة جميع الأعضاء الآخرين من مساحة العمل هذه فورًا.",
"confirm": "تغيير الخطة",
"confirmationPhrase": "أنا أفهم",
"confirmationPrompt": "اكتب \"{phrase}\" للتأكيد.",
"failed": "فشل في تغيير الخطة",
"failedAfterMemberRemoval": "تمت إزالة أعضاء الفريق، لكن لم يكتمل تغيير الخطة — يرجى المحاولة مرة أخرى أو التواصل مع الدعم",
"memberRemovalFailed": "تعذر إزالة {email} من الفريق — قد يكون بعض الأعضاء قد أُزيلوا بالفعل ولم يتم تغيير خطتك",
"notAllowed": "تغيير الخطة غير متاح",
"paymentMethodRequired": "مطلوب وسيلة دفع لتغيير الخطط",
"paymentPageBlocked": "تعذر فتح صفحة الدفع — يرجى المحاولة مرة أخرى",
"title": "تغيير إلى خطة {plan}؟"
},
"endsOnDate": "ينتهي في {date}",
"enterprise": {
"cta": "اعرف المزيد",
"flexibility": "تبحث عن مزيد من المرونة أو ميزات مخصصة؟",
"name": "المؤسسات",
"needMoreMembers": "تحتاج إلى المزيد من الأعضاء؟",
"reachOut": "تواصل معنا ولنحدد موعدًا للحديث."
},
"everythingInPlus": "كل ما في {plan}، بالإضافة إلى:",
"expiresDate": "ينتهي في {date}",
"freePerks": {
"maxRuntime": "مدة تشغيل قصوى {duration}"
},
"freeTier": {
"description": "تشمل خطتك المجانية {credits} رصيد شهري لتجربة Comfy Cloud.",
"descriptionGeneric": "تشمل خطتك المجانية رصيدًا شهريًا لتجربة Comfy Cloud.",
@@ -3730,7 +3823,7 @@
"inviteUpTo": "ادعُ حتى",
"invoiceHistory": "سجل الفواتير",
"learnMore": "معرفة المزيد",
"managePayment": "إدارة الدفع",
"manageBilling": "إدارة الفواتير",
"managePlan": "إدارة الخطة",
"manageSubscription": "إدارة الاشتراك",
"maxDuration": {
@@ -3744,50 +3837,99 @@
"maxMembersLabel": "الحد الأقصى للأعضاء",
"member": "عضو",
"memberCount": "{count} عضو | {count} أعضاء",
"membersLabel": "حتى {count} عضو",
"messageSupport": "مراسلة الدعم",
"monthly": "شهري",
"monthlyBonusDescription": "مكافأة الرصيد الشهرية",
"monthlyCredits": "رصيد شهري",
"monthlyCreditsInfo": "يتم تحديث هذا الرصيد شهريًا ولا ينتقل للشهر التالي",
"monthlyCreditsLabel": "الرصيد الشهري",
"monthlyCreditsPerMemberLabel": "الرصيد الشهري / عضو",
"monthlyCreditsRollover": "سيتم ترحيل هذا الرصيد إلى الشهر التالي",
"monthlyCreditsUsedUpDescription": "أنت الآن تستخدم الرصيد الإضافي.",
"monthlyCreditsUsedUpTitle": "تم استهلاك الرصيد الشهري. إعادة التعبئة {date}",
"monthlyCreditsUsedUpTitleNoDate": "تم استهلاك الرصيد الشهري",
"monthlyUsageProgress": "{used} من {total} من الرصيد الشهري مستخدم",
"mostPopular": "الأكثر شيوعًا",
"needTeamWorkspace": "هل تحتاج إلى مساحة عمل للفريق؟",
"nextBillingCycle": "دورة الفوترة التالية",
"nextMonthInvoice": "فاتورة الشهر القادم",
"outOfCreditsDescription": "أضف المزيد من الرصيد للمتابعة في التوليد.",
"outOfCreditsTitle": "نفد رصيدك. سيتم إعادة التعبئة {date}",
"outOfCreditsTitleNoDate": "نفد رصيدك",
"partnerNodesBalance": "رصيد \"عُقَد الشريك\"",
"partnerNodesCredits": "رصيد العقد الشريكة",
"partnerNodesDescription": "لتشغيل النماذج التجارية/المملوكة",
"partnerNodesPricingTable": "جدول أسعار Partner Nodes",
"perMonth": "دولار أمريكي / شهر",
"personalHeader": "الخطط الشخصية للاستخدام الفردي فقط. {action}",
"personalHeaderAction": "لإضافة زملاء، اشترك في خطة الفريق.",
"personalWorkspace": "مساحة العمل الشخصية",
"planLoadError": "تعذر تحميل تفاصيل خطتك.",
"planLoadErrorRetry": "حاول مرة أخرى",
"planScope": {
"personal": "للاستخدام الشخصي",
"team": "للفرق"
},
"plansAndPricing": "الخطط والأسعار",
"plansForWorkspace": "الخطط لمساحة العمل {workspace}",
"prepaidCreditsInfo": "رصيد تم شراؤه بشكل منفصل ولا ينتهي صلاحيته",
"prepaidDescription": "رصيد مسبق الدفع",
"preview": {
"addCreditCard": "إضافة بطاقة ائتمان",
"afterThat": "بعد ذلك",
"backToAllPlans": "العودة إلى جميع الخطط",
"billedEachMonth": "{amount} يتم تحصيلها كل شهر. يمكنك الإلغاء في أي وقت.",
"commitment": "الالتزام",
"confirm": "تأكيد",
"confirmChange": "تأكيد التغيير",
"confirmChangeTitle": "مراجعة التغيير المجدول",
"confirmPayment": "تأكيد الدفع",
"confirmPlanChange": "تأكيد تغيير الخطة",
"confirmUpgradeCta": "تأكيد الترقية",
"confirmUpgradeTitle": "تأكيد الترقية",
"creditFromCurrent": "رصيد من {plan} الحالي",
"creditsRefillMonthlyTo": "يتم إعادة تعبئة الرصيد شهرياً إلى",
"creditsRefillTo": "سيتم إعادة تعبئة الرصيد إلى",
"creditsYoullGetToday": "الرصيد الذي ستحصل عليه اليوم",
"currentMonthly": "الخطة الشهرية",
"eachMonthCreditsRefill": "يتم إعادة تعبئة الرصيد كل شهر إلى",
"eachYearCreditsRefill": "يتم إعادة تعبئة الرصيد سنوياً إلى",
"ends": "ينتهي في {date}",
"everyMonthStarting": "كل شهر ابتداءً من {date}",
"hideFeatures": "إخفاء الميزات",
"newMonthlySubscription": "اشتراك شهري جديد",
"nextPaymentDue": "الدفع القادم مستحق في {date}. يمكنك الإلغاء في أي وقت.",
"paymentPopupBlocked": "تعذر فتح صفحة الدفع — يرجى السماح بالنوافذ المنبثقة والمحاولة مرة أخرى.",
"perMember": "/ عضو",
"privacyPolicy": "سياسة الخصوصية",
"proratedCharge": "رسوم نسبية لخطة {plan}",
"proratedRefund": "استرداد نسبي لخطة {plan}",
"refillReplacesNote": "يستبدل إعادة التعبئة الشهرية. الرصيد الحالي يبقى محفوظاً.",
"showMoreFeatures": "عرض المزيد من الميزات",
"starting": "يبدأ في {date}",
"startingToday": "يبدأ اليوم",
"startsOn": "يبدأ في {date}",
"stayOnUntil": "ستبقى على {plan} حتى {date}.",
"subscribeToPlan": "اشترك في {plan}",
"switchToPlan": "انتقل إلى {plan}",
"switchesToday": "تبديلات اليوم",
"terms": "الشروط",
"termsAgreement": "بالمتابعة، أنت توافق على {terms} و{privacy} الخاصة بـ Comfy Org.",
"totalDueToday": "الإجمالي المستحق اليوم"
"totalDueToday": "الإجمالي المستحق اليوم",
"yearlySubscription": "اشتراك سنوي",
"youllBeCharged": "سيتم خصم"
},
"pricingBlurb": "*استنادًا إلى هذا القالب، {seeDetails}. تواصل معنا لـ {questions} أو {enterpriseDiscussions}. لمزيد من تفاصيل الأسعار، {clickHere}.",
"pricingBlurbClickHere": "اضغط هنا",
"pricingBlurbEnterprise": "مناقشات المؤسسات",
"pricingBlurbQuestions": "الاستفسارات",
"pricingBlurbSeeDetails": "اعرض التفاصيل",
"reactivatePlan": "إعادة تفعيل الخطة",
"refillsDate": "إعادة التعبئة {date}",
"refillsNextCycle": "إعادة التعبئة في الدورة التالية",
"refreshCredits": "تحديث الرصيد",
"remaining": "متبقي",
"renewsDate": "تجديد في {date}",
"renewsOnDate": "يتجدد في {date}",
"required": {
"pollingFailed": "فشل تفعيل الاشتراك",
"pollingSuccess": "تم تفعيل الاشتراك بنجاح!",
@@ -3799,7 +3941,11 @@
"resubscribe": "إعادة الاشتراك",
"resubscribeSuccess": "تمت إعادة تفعيل الاشتراك بنجاح",
"resubscribeTo": "إعادة الاشتراك في {plan}",
"saveYearly": "وفّر 20%",
"saveYearlyUpTo": "وفر حتى ٢٠٪",
"soloUseOnly": "للاستخدام الفردي فقط",
"subscribe": "اشترك",
"subscribeFailed": "فشل الاشتراك",
"subscribeForMore": "ترقية",
"subscribeNow": "اشترك الآن",
"subscribeTo": "اشترك في {plan}",
@@ -3807,10 +3953,46 @@
"subscribeToRun": "اشتراك",
"subscribeToRunFull": "الاشتراك للتشغيل",
"subscriptionRequiredMessage": "الاشتراك مطلوب للأعضاء لتشغيل سير العمل على السحابة",
"success": {
"allSet": "تم كل شيء بنجاح",
"inviteEmailsPlaceholder": "أدخل عناوين البريد الإلكتروني مفصولة بفواصل",
"inviteSubtext": "يمكنك أيضاً دعوة الأشخاص لاحقاً من الإعدادات",
"inviteTitle": "دعوة فريقك",
"planUpdated": "تم تحديث خطتك بنجاح.",
"receiptEmailed": "تم إرسال إيصال إلى بريدك الإلكتروني.",
"sendInvites": "إرسال الدعوات"
},
"teamHeader": "للفرق التي ترغب في التعاون. تحتاج إلى المزيد من الأعضاء؟ {learnMore} حول المؤسسات.",
"teamHeaderLearnMore": "اعرف المزيد",
"teamPerks": {
"concurrentRuns": "يمكن للأعضاء تشغيل سير العمل في نفس الوقت",
"inviteMembers": "دعوة الأعضاء",
"rolePermissions": "أذونات حسب الدور",
"sharedCreditPool": "رصيد مشترك لجميع الأعضاء"
},
"teamPlan": {
"changePlan": "تغيير الخطة",
"comingSoonLabel": "قريبًا:",
"cta": "اشترك في خطة الفريق السنوية",
"ctaMonthly": "اشترك في خطة الفريق الشهرية",
"currentPlan": "الخطة الحالية",
"detailsTitle": "التفاصيل",
"name": "خطة الفريق",
"perkConcurrentRuns": "يمكن للأعضاء تشغيل سير العمل في نفس الوقت",
"perkInviteMembers": "دعوة أعضاء الفريق",
"perkProjectAssets": "إدارة المشاريع والأصول",
"perkRolePermissions": "أذونات حسب الدور",
"perkSharedPool": "رصيد مشترك لجميع الأعضاء",
"tagline": "اختر اشتراك الرصيد الشهري الخاص بك. احصل على خصم أكبر مع اشتراك رصيد أكبر.",
"unavailable": "خطة الفريق هذه غير متوفرة حالياً."
},
"teamPlanIncludes": "تشمل خطتك كل ما في {plan}، بالإضافة إلى:",
"teamPlanName": "فريق",
"teamWorkspace": "مساحة عمل الفريق",
"tierNameYearly": "{name} سنوي",
"tiers": {
"creator": {
"feature1": "استيراد نماذجك الخاصة",
"name": "المُبدع"
},
"founder": {
@@ -3820,9 +4002,12 @@
"name": "مجاني"
},
"pro": {
"feature1": "مدة تشغيل سير عمل أطول (حتى ساعة واحدة)",
"name": "احترافي"
},
"standard": {
"feature1": "حد أقصى لمدة تشغيل سير العمل ٣٠ دقيقة",
"feature2": "إضافة المزيد من الرصيد في أي وقت",
"name": "قياسي"
}
},
@@ -3835,6 +4020,8 @@
"upgradeToAddCredits": "قم بالترقية لإضافة أرصدة",
"usdPerMonth": "دولار أمريكي / شهريًا",
"usdPerMonthPerMember": "دولار أمريكي / شهر / عضو",
"usedAfterMonthly": "يتم استخدامه بعد نفاد الرصيد الشهري",
"videoEstimate": "ينتج تقريبًا ~{count} فيديوهات ٥ ثوانٍ*",
"videoEstimateExplanation": "هذه التقديرات مبنية على قالب Wan 2.2 لتحويل الصورة إلى فيديو باستخدام الإعدادات الافتراضية (5 ثوانٍ، 640x640، 16 إطار/ثانية، 4 خطوات أخذ عينات).",
"videoEstimateHelp": "مزيد من التفاصيل حول هذا القالب",
"videoEstimateLabel": "العدد التقريبي لمقاطع الفيديو 5 ثوانٍ التي يتم إنشاؤها باستخدام قالب Wan 2.2 لتحويل الصورة إلى فيديو",
@@ -3844,10 +4031,10 @@
"viewMoreDetails": "عرض المزيد من التفاصيل",
"viewMoreDetailsPlans": "عرض المزيد من التفاصيل حول الخطط والأسعار",
"viewUsageHistory": "عرض سجل الاستخدام",
"whatsIncluded": "ما يتضمنه:",
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
"yearly": "سنوي",
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
"yearlyDiscount": "خصم 20%",
"yourPlanIncludes": "خطتك تشمل:"
},
"tabMenu": {
@@ -4138,6 +4325,19 @@
}
},
"workspacePanel": {
"changeRoleDialog": {
"demoteConfirm": "تخفيض إلى عضو",
"demoteMessage": "سيفقد صلاحيات الإدارة.",
"demoteTitle": "تخفيض {name} إلى عضو؟",
"error": "فشل في تحديث الدور",
"promoteConfirm": "جعل مالكًا",
"promoteIntro": "سيكون بإمكانه:",
"promotePermissionCredits": "إضافة أرصدة إضافية",
"promotePermissionManage": "إدارة الأعضاء، طرق الدفع، وإعدادات مساحة العمل",
"promotePermissionRoles": "ترقية أو تخفيض مالكين آخرين (باستثناء منشئ مساحة العمل).",
"promoteTitle": "جعل {name} مالكًا؟",
"success": "تم تحديث الدور"
},
"createWorkspaceDialog": {
"create": "إنشاء",
"message": "تتيح مساحات العمل للأعضاء مشاركة رصيد واحد. ستصبح المالك بعد الإنشاء.",
@@ -4162,17 +4362,11 @@
"inviteLimitReached": "لقد وصلت إلى الحد الأقصى وهو ٥٠ عضواً",
"inviteMember": "دعوة عضو",
"inviteMemberDialog": {
"createLink": "إنشاء الرابط",
"linkCopied": "تم النسخ",
"linkCopyFailed": "فشل في نسخ الرابط",
"linkStep": {
"copyLink": "نسخ الرابط",
"done": "تم",
"message": "تأكد من أن حسابه يستخدم هذا البريد الإلكتروني.",
"title": "أرسل هذا الرابط إلى الشخص"
},
"message": "أنشئ رابط دعوة قابل للمشاركة لإرساله إلى شخص ما",
"failedCount": "تعذر إرسال {count} دعوة. حاول مرة أخرى. | تعذر إرسال {count} دعوات. حاول مرة أخرى.",
"invalidEmailCount": "{count} عنوان بريد إلكتروني غير صالح | {count} عناوين بريد إلكتروني غير صالحة",
"invitedMessage": "تم إرسال دعوة إلى {emails} | تم إرسال دعوات إلى {emails}",
"placeholder": "أدخل بريد الشخص الإلكتروني",
"seatLimitReached": "يمكنك دعوة حتى {count} زميل. | يمكنك دعوة حتى {count} زملاء.",
"title": "دعوة شخص إلى هذه المساحة"
},
"inviteUpsellDialog": {
@@ -4180,8 +4374,7 @@
"messageSingleSeat": "خطة Standard تتضمن مقعدًا واحدًا فقط لمالك مساحة العمل. لدعوة أعضاء إضافيين، قم بالترقية إلى خطة Creator أو أعلى لتفعيل المقاعد المتعددة.",
"titleNotSubscribed": "الاشتراك مطلوب لدعوة الأعضاء",
"titleSingleSeat": "خطتك الحالية تدعم مقعدًا واحدًا فقط",
"upgradeToCreator": "الترقية إلى Creator",
"viewPlans": "عرض الخطط"
"upgradeToTeam": "الترقية إلى فريق"
},
"leaveDialog": {
"leave": "مغادرة",
@@ -4190,30 +4383,35 @@
},
"members": {
"actions": {
"copyLink": "نسخ رابط الدعوة",
"cancelInvite": "إلغاء الدعوة",
"changeRole": "تغيير الدور",
"removeMember": "إزالة العضو",
"revokeInvite": "إلغاء الدعوة"
"resendInvite": عادة إرسال الدعوة"
},
"columns": {
"expiryDate": "تاريخ الانتهاء",
"inviteDate": "تاريخ الدعوة",
"joinDate": "تاريخ الانضمام"
"role": "الدور"
},
"createNewWorkspace": "أنشئ واحدة جديدة.",
"contactUs": "تواصل معنا",
"header": "الأعضاء",
"membersCount": "{count}/٥٠ عضواً",
"needMoreMembers": "تحتاج إلى المزيد من الأعضاء؟",
"noInvites": "لا توجد دعوات معلقة",
"noMembers": "لا يوجد أعضاء",
"pendingInvitesCount": "{count} دعوة معلقة | {count} دعوات معلقة",
"personalWorkspaceMessage": "لا يمكنك دعوة أعضاء آخرين إلى مساحة العمل الشخصية حالياً. لإضافة أعضاء إلى مساحة عمل،",
"reactivateTeam": "إعادة تفعيل الفريق",
"searchPlaceholder": "بحث...",
"tabs": {
"active": "نشط",
"pendingCount": "معلق ({count})"
},
"upsellBannerSubscribe": "اشترك في خطة Creator أو أعلى لدعوة أعضاء الفريق إلى مساحة العمل هذه.",
"upsellBannerUpgrade": "قم بالترقية إلى خطة Creator أو أعلى لدعوة أعضاء فريق إضافيين.",
"viewPlans": "عرض الخطط"
"upgradeToTeam": "الترقية إلى فريق",
"upsellBanner": "لإضافة زملاء، قم بترقية خطتك.",
"upsellBannerReactivate": "لإضافة المزيد من الزملاء، فعّل خطتك من جديد."
},
"menu": {
"creatorCannotLeave": "لا يمكن لمنشئ مساحة العمل مغادرة المساحة التي أنشأها",
"deleteWorkspace": "حذف مساحة العمل",
"deleteWorkspaceDisabledTooltip": "يرجى إلغاء الاشتراك النشط لمساحة العمل أولاً",
"editWorkspace": "تعديل تفاصيل مساحة العمل",
@@ -4242,6 +4440,8 @@
"failedToFetchWorkspaces": "فشل في تحميل مساحات العمل",
"failedToLeaveWorkspace": "فشل في مغادرة مساحة العمل",
"failedToUpdateWorkspace": "فشل في تحديث مساحة العمل",
"inviteResendFailed": "فشل في إعادة إرسال الدعوة",
"inviteResent": "تمت إعادة إرسال الدعوة",
"workspaceCreated": {
"message": "اشترك في خطة، وادعُ زملاءك، وابدأ التعاون.",
"subscribe": "اشترك",

View File

@@ -730,6 +730,49 @@
}
}
},
"BuildJsonPromptIdeogram": {
"description": "إنشاء موجه JSON لنموذج Ideogram 4.",
"display_name": "إنشاء موجه JSON (Ideogram)",
"inputs": {
"aesthetics": {
"name": "الجماليات",
"tooltip": "كلمات مفتاحية جمالية إلزامية (مثل: مزاجي، سينمائي، باهت الألوان)."
},
"background": {
"name": "الخلفية",
"tooltip": "وصف إلزامي لخلفية الصورة أو البيئة المحيطة."
},
"color_palette": {
"name": "لوحة الألوان",
"tooltip": "رموز ألوان Hex لتوجيه الألوان السائدة في الصورة. حتى ١٦ إدخالاً."
},
"element": {
"name": "عنصر",
"tooltip": "عناصر الموجه من عقدة إنشاء الصناديق المحيطة."
},
"high_level_description": {
"name": "وصف عالي المستوى",
"tooltip": "وصف اختياري للصورة في جملة أو جملتين. يُنصح به بشدة."
},
"lighting": {
"name": "الإضاءة",
"tooltip": "وصف إلزامي للإضاءة (مثل: ساعة ذهبية، إضاءة حواف، ظلال درامية)."
},
"medium": {
"name": "الوسيط",
"tooltip": "نوع الوسيط الإلزامي (مثل: صورة فوتوغرافية، رسم توضيحي، ثلاثي الأبعاد، لوحة، تصميم جرافيكي). عند اختيار النمط = صورة، اختر صورة فوتوغرافية."
},
"style": {
"name": "النمط"
}
},
"outputs": {
"0": {
"name": "موجه",
"tooltip": null
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "إنشاء فيديو باستخدام Seedance 2.0 من صورة الإطار الأول وصورة الإطار الأخير (اختياري).",
"display_name": "ByteDance Seedance 2.0 من الإطار الأول/الأخير إلى فيديو",
@@ -2633,6 +2676,40 @@
}
}
},
"ConvertArrayToString": {
"display_name": "تحويل مصفوفة إلى نص",
"inputs": {
"array": {
"name": "مصفوفة"
},
"indent": {
"name": "المسافة البادئة",
"tooltip": "عدد المسافات لكل مستوى مسافة بادئة. ٠ ينتج نصاً مضغوطاً في سطر واحد."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ConvertDictionaryToString": {
"display_name": "تحويل قاموس إلى نص",
"inputs": {
"dictionary": {
"name": "قاموس"
},
"indent": {
"name": "المسافة البادئة",
"tooltip": "عدد المسافات لكل مستوى مسافة بادئة. ٠ ينتج نصاً مضغوطاً في سطر واحد."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"CosmosImageToVideoLatent": {
"display_name": "تحويل صورة كوزموس إلى فيديو كامِن",
"inputs": {
@@ -2695,6 +2772,42 @@
}
}
},
"CreateBoundingBoxes": {
"description": "رسم صناديق محيطة في لوحة الرسم. يخرج عناصر موجه Ideogram، وصناديق محيطة في مساحة البكسل، وصورة معاينة.",
"display_name": "إنشاء صناديق محيطة",
"inputs": {
"background": {
"name": "الخلفية",
"tooltip": "صورة اختيارية تُستخدم كخلفية في لوحة الرسم والمعاينة."
},
"editor_state": {
"name": "حالة المحرر",
"tooltip": "ارسم الصناديق المحيطة وحدد نوع كل صندوق، النص، الوصف، لوحة الألوان. ابدأ بعنصر الخلفية أولاً والعناصر الأمامية أخيراً."
},
"height": {
"name": "الارتفاع",
"tooltip": "ارتفاع لوحة الرسم وشبكة البكسل للصناديق المحيطة."
},
"width": {
"name": "العرض",
"tooltip": "عرض لوحة الرسم وشبكة البكسل للصناديق المحيطة."
}
},
"outputs": {
"0": {
"name": "معاينة",
"tooltip": null
},
"1": {
"name": "صناديق محيطة",
"tooltip": null
},
"2": {
"name": "عناصر",
"tooltip": null
}
}
},
"CreateCameraInfo": {
"description": "إنشاء camera_info. وضع 'orbit' يوجه الكاميرا حول الهدف باستخدام yaw/pitch/distance؛ وضع 'look_at' يضع الكاميرا في موقع محدد في العالم. الإحداثيات في فضاء العالم للمشاهد (نظام اليد اليمنى، المحور Y للأعلى).",
"display_name": "إنشاء معلومات الكاميرا",
@@ -12025,6 +12138,131 @@
}
}
},
"ModelMergeKrea2": {
"display_name": "ModelMergeKrea2",
"inputs": {
"blocks_0_": {
"name": "الكتل.0."
},
"blocks_10_": {
"name": "الكتل.10."
},
"blocks_11_": {
"name": "الكتل.11."
},
"blocks_12_": {
"name": "الكتل.12."
},
"blocks_13_": {
"name": "الكتل.13."
},
"blocks_14_": {
"name": "الكتل.14."
},
"blocks_15_": {
"name": "الكتل.15."
},
"blocks_16_": {
"name": "الكتل.16."
},
"blocks_17_": {
"name": "الكتل.17."
},
"blocks_18_": {
"name": "الكتل.18."
},
"blocks_19_": {
"name": "الكتل.19."
},
"blocks_1_": {
"name": "الكتل.1."
},
"blocks_20_": {
"name": "الكتل.20."
},
"blocks_21_": {
"name": "الكتل.21."
},
"blocks_22_": {
"name": "الكتل.22."
},
"blocks_23_": {
"name": "الكتل.23."
},
"blocks_24_": {
"name": "الكتل.24."
},
"blocks_25_": {
"name": "الكتل.25."
},
"blocks_26_": {
"name": "الكتل.26."
},
"blocks_27_": {
"name": "الكتل.27."
},
"blocks_2_": {
"name": "الكتل.2."
},
"blocks_3_": {
"name": "الكتل.3."
},
"blocks_4_": {
"name": "الكتل.4."
},
"blocks_5_": {
"name": "الكتل.5."
},
"blocks_6_": {
"name": "الكتل.6."
},
"blocks_7_": {
"name": "الكتل.7."
},
"blocks_8_": {
"name": "الكتل.8."
},
"blocks_9_": {
"name": "الكتل.9."
},
"first_": {
"name": "الأول."
},
"last_": {
"name": "الأخير."
},
"model1": {
"name": "model1"
},
"model2": {
"name": "model2"
},
"tmlp_": {
"name": "tmlp."
},
"tproj_": {
"name": "tproj."
},
"txtfusion_layerwise_blocks_0_": {
"name": "txtfusion.layerwise_blocks.0."
},
"txtfusion_layerwise_blocks_1_": {
"name": "txtfusion.layerwise_blocks.1."
},
"txtfusion_projector_": {
"name": "txtfusion.projector."
},
"txtfusion_refiner_blocks_0_": {
"name": "txtfusion.refiner_blocks.0."
},
"txtfusion_refiner_blocks_1_": {
"name": "txtfusion.refiner_blocks.1."
},
"txtmlp_": {
"name": "txtmlp."
}
}
},
"ModelMergeLTXV": {
"display_name": "ModelMergeLTXV",
"inputs": {
@@ -17698,6 +17936,23 @@
}
}
},
"SeedNode": {
"display_name": "البذرة",
"inputs": {
"fixed": {
"name": "التحكم بعد التوليد"
},
"seed": {
"name": "البذرة"
}
},
"outputs": {
"0": {
"name": "البذرة",
"tooltip": null
}
}
},
"SelectCLIPDevice": {
"description": "ضع مشفر نص CLIP على جهاز محدد (افتراضي / cpu / gpu:N).\n\n- \"default\" يعيد الجهاز الذي تم تعيينه من قبل أداة التحميل.\n- \"cpu\" يثبت كل من جهاز التحميل والتفريغ على وحدة المعالجة المركزية.\n- \"gpu:N\" يثبت جهاز التحميل على وحدة معالجة الرسومات رقم N المتوفرة.\n\nعندما لا يكون الجهاز المحدد موجودًا على الجهاز الحالي\n(مثلاً سير عمل تم بناؤه على جهاز به وحدتي GPU وتم فتحه على جهاز به وحدة GPU واحدة)،\nتمرر العقدة CLIP كما هو دون تغيير وتقوم بتسجيل رسالة\nبدلاً من الفشل.",
"display_name": "اختيار جهاز CLIP",

View File

@@ -895,8 +895,8 @@
"nodes": "Nodes",
"models": "Models",
"assets": "Assets",
"workflows": "Work\u00adflows",
"templates": "Tem\u00adplates",
"workflows": "Work­flows",
"templates": "Tem­plates",
"console": "Console",
"menu": "Menu",
"imported": "Imported",
@@ -1828,6 +1828,7 @@
},
"dataTypes": {
"*": "*",
"ARRAY": "ARRAY",
"AUDIO": "AUDIO",
"AUDIO_ENCODER": "AUDIO_ENCODER",
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
@@ -1835,11 +1836,13 @@
"BACKGROUND_REMOVAL": "BACKGROUND_REMOVAL",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOUNDING_BOX",
"BOUNDING_BOXES": "BOUNDING_BOXES",
"CAMERA_CONTROL": "CAMERA_CONTROL",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
"COLOR": "COLOR",
"COLORS": "COLORS",
"COMBO": "COMBO",
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
@@ -1849,6 +1852,7 @@
"CURVE": "CURVE",
"DA3_GEOMETRY": "DA3_GEOMETRY",
"DA3_MODEL": "DA3_MODEL",
"DICT": "DICT",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "FACE_DETECTION_MODEL",
"FACE_LANDMARKS": "FACE_LANDMARKS",
@@ -2233,6 +2237,11 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"dynamicGroup": {
"addRow": "Add row",
"removeRow": "Remove row",
"row": "Row {index}"
},
"oauth": {
"consent": {
"allow": "Continue",
@@ -2347,6 +2356,11 @@
"personalDataConsentLabel": "I agree to the processing of my personal data.",
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
},
"turnstile": {
"failed": "Verification failed. Please try again.",
"expired": "Verification expired. Please complete the challenge again.",
"submitBlockedHint": "Complete the verification challenge above to enable sign up."
},
"signOut": {
"signOut": "Log Out",
"success": "Signed out successfully",
@@ -2507,13 +2521,18 @@
"creditSliderSave": "Save {percent}% ({amount})",
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"renewsOnDate": "Renews on {date}",
"endsOnDate": "Ends on {date}",
"manageSubscription": "Manage subscription",
"managePayment": "Manage Payment",
"cancelSubscription": "Cancel Subscription",
"manageBilling": "Manage billing",
"changePlan": "Change plan",
"cancelPlan": "Cancel plan",
"canceled": "Canceled",
"resubscribe": "Resubscribe",
"reactivatePlan": "Reactivate plan",
"resubscribeTo": "Resubscribe to {plan}",
"resubscribeSuccess": "Subscription reactivated successfully",
"subscribeFailed": "Failed to subscribe",
"canceledCard": {
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."
@@ -2553,16 +2572,48 @@
"creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",
"remaining": "remaining",
"refillsDate": "Refills {date}",
"refillsNextCycle": "Refills next cycle",
"creditsUsed": "{used} used",
"creditsLeftOfTotal": "{remaining} left of {total}",
"monthlyUsageProgress": "{used} of {total} monthly credits used",
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
"outOfCreditsTitle": "You're out of credits. Credits refill {date}",
"outOfCreditsTitleNoDate": "You're out of credits",
"outOfCreditsDescription": "Add more credits to continue generating.",
"additionalCreditsTooltip": "Credits you add on top of your plan. Used after monthly credits run out. Each expires one year after purchase.",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"whatsIncluded": "What's included:",
"planLoadError": "We couldn't load your plan details.",
"planLoadErrorRetry": "Try again",
"teamPlanName": "Team",
"teamPlanIncludes": "Your plan includes everything in {plan}, plus:",
"teamPerks": {
"inviteMembers": "Invite members",
"concurrentRuns": "Members can run workflows concurrently",
"sharedCreditPool": "Shared credit pool for all members",
"rolePermissions": "Role-based permissions"
},
"freePerks": {
"maxRuntime": "{duration} max runtime"
},
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"billedMonthly": "Billed monthly",
"billedYearly": "{total} Billed yearly",
"monthly": "Monthly",
"yearly": "Yearly",
"saveYearly": "Save 20%",
"tierNameYearly": "{name} Yearly",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
@@ -2573,7 +2624,6 @@
"benefit2": "Up to 1 hour runtime per job on Pro",
"benefit3": "Bring your own models (Creator & Pro)"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"free": {
"name": "Free"
@@ -2614,6 +2664,7 @@
"subscribeToRunFull": "Subscribe to Run",
"subscribeForMore": "Upgrade",
"upgradeToAddCredits": "Upgrade to add credits",
"subscribe": "Subscribe",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
@@ -2632,11 +2683,9 @@
"teamHeaderLearnMore": "Learn more",
"personalHeader": "Personal plans are for individual use only. {action}",
"personalHeaderAction": "To add teammates, subscribe to the team plan.",
"whatsIncluded": "What's included:",
"everythingInPlus": "Everything in {plan}, plus:",
"monthlyCredits": "monthly credits",
"videoEstimate": "Generates ~{count} 5s videos*",
"saveYearly": "Save 20%",
"saveYearlyUpTo": "Save up to 20%",
"teamPlan": {
"name": "Team Plan",
@@ -2650,9 +2699,9 @@
"perkProjectAssets": "Project & asset management",
"cta": "Subscribe to Team Yearly",
"ctaMonthly": "Subscribe to Team Monthly",
"unavailable": "This team plan is not available right now.",
"changePlan": "Change plan",
"currentPlan": "Current plan",
"checkoutComingSoon": "Team plan checkout is coming soon."
"currentPlan": "Current plan"
},
"enterprise": {
"name": "Enterprise",
@@ -2682,6 +2731,7 @@
"upgradeCta": "View plans"
},
"partnerNodesCredits": "Partner nodes pricing",
"partnerNodesPricingTable": "Partner Nodes pricing table",
"plansAndPricing": "Plans & pricing",
"managePlan": "Manage plan",
"upgrade": "UPGRADE",
@@ -2692,8 +2742,6 @@
"monthlyCreditsPerMemberLabel": "Monthly credits / member",
"maxMembersLabel": "Max. members",
"yearlyCreditsLabel": "Total yearly credits",
"membersLabel": "Up to {count} members",
"nextMonthInvoice": "Next month invoice",
"memberCount": "{count} member | {count} members",
"maxDurationLabel": "Max run duration",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
@@ -2708,7 +2756,7 @@
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"maxDuration": {
"free": "30 min",
"free": "10 min",
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr",
@@ -2721,10 +2769,11 @@
"preview": {
"confirmPayment": "Confirm your payment",
"confirmPlanChange": "Confirm your plan change",
"startingToday": "Starting today",
"startingToday": "Starts today",
"starting": "Starting {date}",
"ends": "Ends {date}",
"eachMonthCreditsRefill": "Each month credits refill to",
"eachYearCreditsRefill": "Each year credits refill to",
"everyMonthStarting": "Every month starting {date}",
"creditsRefillTo": "Credits refill to",
"youllBeCharged": "You'll be charged",
@@ -2735,6 +2784,24 @@
"proratedCharge": "Prorated charge for {plan}",
"totalDueToday": "Total due today",
"nextPaymentDue": "Next payment due {date}. Cancel anytime.",
"confirmUpgradeTitle": "Confirm your upgrade",
"confirmUpgradeCta": "Confirm upgrade",
"confirmChange": "Confirm change",
"confirmChangeTitle": "Review your scheduled change",
"paymentPopupBlocked": "Couldn't open the payment page — please allow popups and try again.",
"switchesToday": "Switches today",
"startsOn": "Starts {date}",
"yearlySubscription": "Yearly subscription",
"newMonthlySubscription": "New monthly subscription",
"creditFromCurrent": "Credit from current {plan}",
"currentMonthly": "monthly plan",
"commitment": "commitment",
"creditsYoullGetToday": "Credits you'll get today",
"refillReplacesNote": "Replaces your monthly refill. Existing balance is kept.",
"afterThat": "After that",
"creditsRefillMonthlyTo": "Credits refill monthly to",
"billedEachMonth": "{amount} billed each month. Cancel anytime.",
"stayOnUntil": "You'll stay on {plan} until {date}.",
"termsAgreement": "By continuing, you agree to Comfy Org's {terms} and {privacy}.",
"terms": "Terms",
"privacyPolicy": "Privacy Policy",
@@ -2746,8 +2813,12 @@
},
"success": {
"allSet": "You're all set",
"inviteEmailsPlaceholder": "Enter emails separated by commas",
"inviteSubtext": "You can also invite people later from Settings",
"inviteTitle": "Invite your team",
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you."
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
}
},
"userSettings": {
@@ -2763,7 +2834,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"inviteLimitReached": "You've reached the maximum of {count} members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2773,7 +2844,8 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/{maxSeats} Members",
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
@@ -2782,26 +2854,30 @@
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
"role": "Role"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"resendInvite": "Resend invite",
"cancelInvite": "Cancel invite",
"changeRole": "Change role",
"removeMember": "Remove member"
},
"upsellBannerSubscribe": "Subscribe to the Creator plan or above to invite team members to this workspace.",
"upsellBannerUpgrade": "Upgrade to the Creator plan or above to invite additional team members.",
"viewPlans": "View plans",
"upsellBanner": "To add teammates, upgrade your plan.",
"upsellBannerReactivate": "To add more teammates, reactivate your plan.",
"upgradeToTeam": "Upgrade to Team",
"reactivateTeam": "Reactivate Team",
"needMoreMembers": "Need more members?",
"contactUs": "Contact us",
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
"searchPlaceholder": "Search..."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2825,32 +2901,38 @@
"success": "Member removed",
"error": "Failed to remove member"
},
"changeRoleDialog": {
"promoteTitle": "Make {name} an owner?",
"promoteIntro": "They'll be able to:",
"promotePermissionCredits": "Add additional credits",
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
"promoteConfirm": "Make owner",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
"demoteConfirm": "Demote to member",
"success": "Role updated",
"error": "Failed to update role"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteUpsellDialog": {
"titleNotSubscribed": "A subscription is required to invite members",
"titleNotSubscribed": "A Team plan is required to invite members",
"titleSingleSeat": "Your current plan supports a single seat",
"messageNotSubscribed": "To add team members to this workspace, you need a Creator plan or above. The Standard plan supports only a single seat (the owner).",
"messageSingleSeat": "The Standard plan includes one seat for the workspace owner. To invite additional members, upgrade to the Creator plan or above to unlock multiple seats.",
"viewPlans": "View Plans",
"upgradeToCreator": "Upgrade to Creator"
"messageNotSubscribed": "To add teammates to this workspace, upgrade to a Team plan.",
"messageSingleSeat": "Your current plan includes one seat for the workspace owner. To add teammates, upgrade to a Team plan.",
"upgradeToTeam": "Upgrade to Team"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
"title": "Invite members to this workspace",
"placeholder": "Enter emails separated by commas",
"invalidEmailCount": "{count} invalid email address | {count} invalid email addresses",
"failedCount": "Couldn't send {count} invite. Try again. | Couldn't send {count} invites. Try again.",
"invitedMessage": "An invite was sent to {emails} | Invites were sent to {emails}",
"seatLimitReached": "You can invite up to {count} teammate. | You can invite up to {count} teammates."
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
@@ -2877,6 +2959,8 @@
"title": "Left workspace",
"message": "You have left the workspace."
},
"inviteResent": "Invite resent",
"inviteResendFailed": "Failed to resend invite",
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
@@ -2990,7 +3074,7 @@
"share": "Share"
},
"shortcuts": {
"shortcuts": "Short\u00adcuts",
"shortcuts": "Short­cuts",
"essentials": "Essential",
"viewControls": "View Controls",
"manageShortcuts": "Manage Shortcuts",
@@ -3594,6 +3678,8 @@
},
"linearMode": {
"linearMode": "App Mode",
"beta": "App mode in beta",
"buildAnApp": "Build an app",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
@@ -3626,10 +3712,7 @@
"appBuilder": "App builder",
"apps": "Apps",
"appsEmptyMessage": "Saved apps will show up here.",
"appsEmptyMessageAction": "Click below to build your first app.",
"buildAnApp": "Build an app",
"create": "Create",
"createApp": "Create app"
"appsEmptyMessageAction": "Click below to build your first app."
},
"arrange": {
"noOutputs": "No outputs added yet",
@@ -3788,7 +3871,6 @@
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",
"getHelpTooltip": "Report this error and we'll help you resolve it",
"enterSubgraph": "Enter subgraph",
"seeError": "See Error",
"errorHelp": "For more help, {github} or {support}",
"errorHelpGithub": "submit a GitHub issue",

View File

@@ -730,13 +730,56 @@
}
}
},
"BuildJsonPromptIdeogram": {
"display_name": "Build JSON Prompt (Ideogram)",
"description": "Build a JSON prompt for the Ideogram 4 model.",
"inputs": {
"element": {
"name": "element",
"tooltip": "Prompt elements from the node Create Bounding Boxes."
},
"high_level_description": {
"name": "high_level_description",
"tooltip": "Optional description of the image in one or two sentences. Strongly recommended."
},
"background": {
"name": "background",
"tooltip": "Mandatory description of the image background or environment."
},
"style": {
"name": "style"
},
"aesthetics": {
"name": "aesthetics",
"tooltip": "Mandatory aesthetic keywords (e.g. moody, cinematic, desaturated)."
},
"lighting": {
"name": "lighting",
"tooltip": "Mandatory lighting description (e.g. golden hour, rim light, dramatic shadows)."
},
"medium": {
"name": "medium",
"tooltip": "Mandatory medium type (e.g. photograph, illustration, 3d_render, painting, graphic_design). When style = photo, set to photograph."
},
"color_palette": {
"name": "color_palette",
"tooltip": "Hex color codes that steer the image's dominant colors. Up to 16 entries."
}
},
"outputs": {
"0": {
"name": "prompt",
"tooltip": null
}
}
},
"ByteDance2FirstLastFrameNode": {
"display_name": "ByteDance Seedance 2.0 First-Last-Frame to Video",
"description": "Generate video using Seedance 2.0 from a first frame image and optional last frame image.",
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
"tooltip": "Seedance 2.0 for maximum quality; Fast for speed optimization; Mini for the fastest, lowest-cost generation."
},
"seed": {
"name": "seed",
@@ -793,7 +836,7 @@
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
"tooltip": "Seedance 2.0 for maximum quality; Fast for speed optimization; Mini for the fastest, lowest-cost generation."
},
"seed": {
"name": "seed",
@@ -840,7 +883,7 @@
"inputs": {
"model": {
"name": "model",
"tooltip": "Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization."
"tooltip": "Seedance 2.0 for maximum quality; Fast for speed optimization; Mini for the fastest, lowest-cost generation."
},
"seed": {
"name": "seed",
@@ -2633,6 +2676,40 @@
}
}
},
"ConvertArrayToString": {
"display_name": "Convert Array to String",
"inputs": {
"array": {
"name": "array"
},
"indent": {
"name": "indent",
"tooltip": "Spaces per indent level. 0 produces compact single-line string."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ConvertDictionaryToString": {
"display_name": "Convert Dictionary to String",
"inputs": {
"dictionary": {
"name": "dictionary"
},
"indent": {
"name": "indent",
"tooltip": "Spaces per indent level. 0 produces compact single-line string."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"CosmosImageToVideoLatent": {
"display_name": "CosmosImageToVideoLatent",
"inputs": {
@@ -2695,6 +2772,42 @@
}
}
},
"CreateBoundingBoxes": {
"display_name": "Create Bounding Boxes",
"description": "Draw bounding boxes in a canvas. Outputs Ideogram prompt elements, pixel-space bounding boxes, and a preview image.",
"inputs": {
"width": {
"name": "width",
"tooltip": "Width of the canvas and the pixel grid for the bounding boxes."
},
"height": {
"name": "height",
"tooltip": "Height of the canvas and the pixel grid for the bounding boxes."
},
"editor_state": {
"name": "editor_state",
"tooltip": "Draw bounding boxes and set each box type, text, description, color palette. Start with background element first and foreground last."
},
"background": {
"name": "background",
"tooltip": "Optional image used as background in the canvas and preview."
}
},
"outputs": {
"0": {
"name": "preview",
"tooltip": null
},
"1": {
"name": "bboxes",
"tooltip": null
},
"2": {
"name": "elements",
"tooltip": null
}
}
},
"CreateCameraInfo": {
"display_name": "Create Camera Info",
"description": "Build a camera_infoMode 'orbit' aims with yaw/pitch/distance around the target; 'look_at' places the camera at world position. Coordinates are the viewer's world space (right-handed,Y-up).",
@@ -5419,7 +5532,7 @@
},
"resolution": {
"name": "resolution",
"tooltip": "The resolution of the output video."
"tooltip": "The resolution of the output video. 1080p is only available for grok-imagine-video-1.5."
},
"aspect_ratio": {
"name": "aspect_ratio",
@@ -11905,6 +12018,131 @@
}
}
},
"ModelMergeKrea2": {
"display_name": "ModelMergeKrea2",
"inputs": {
"model1": {
"name": "model1"
},
"model2": {
"name": "model2"
},
"first_": {
"name": "first."
},
"tmlp_": {
"name": "tmlp."
},
"txtmlp_": {
"name": "txtmlp."
},
"tproj_": {
"name": "tproj."
},
"txtfusion_layerwise_blocks_0_": {
"name": "txtfusion.layerwise_blocks.0."
},
"txtfusion_layerwise_blocks_1_": {
"name": "txtfusion.layerwise_blocks.1."
},
"txtfusion_projector_": {
"name": "txtfusion.projector."
},
"txtfusion_refiner_blocks_0_": {
"name": "txtfusion.refiner_blocks.0."
},
"txtfusion_refiner_blocks_1_": {
"name": "txtfusion.refiner_blocks.1."
},
"blocks_0_": {
"name": "blocks.0."
},
"blocks_1_": {
"name": "blocks.1."
},
"blocks_2_": {
"name": "blocks.2."
},
"blocks_3_": {
"name": "blocks.3."
},
"blocks_4_": {
"name": "blocks.4."
},
"blocks_5_": {
"name": "blocks.5."
},
"blocks_6_": {
"name": "blocks.6."
},
"blocks_7_": {
"name": "blocks.7."
},
"blocks_8_": {
"name": "blocks.8."
},
"blocks_9_": {
"name": "blocks.9."
},
"blocks_10_": {
"name": "blocks.10."
},
"blocks_11_": {
"name": "blocks.11."
},
"blocks_12_": {
"name": "blocks.12."
},
"blocks_13_": {
"name": "blocks.13."
},
"blocks_14_": {
"name": "blocks.14."
},
"blocks_15_": {
"name": "blocks.15."
},
"blocks_16_": {
"name": "blocks.16."
},
"blocks_17_": {
"name": "blocks.17."
},
"blocks_18_": {
"name": "blocks.18."
},
"blocks_19_": {
"name": "blocks.19."
},
"blocks_20_": {
"name": "blocks.20."
},
"blocks_21_": {
"name": "blocks.21."
},
"blocks_22_": {
"name": "blocks.22."
},
"blocks_23_": {
"name": "blocks.23."
},
"blocks_24_": {
"name": "blocks.24."
},
"blocks_25_": {
"name": "blocks.25."
},
"blocks_26_": {
"name": "blocks.26."
},
"blocks_27_": {
"name": "blocks.27."
},
"last_": {
"name": "last."
}
}
},
"ModelMergeLTXV": {
"display_name": "ModelMergeLTXV",
"inputs": {
@@ -17577,6 +17815,23 @@
}
}
},
"SeedNode": {
"display_name": "Seed",
"inputs": {
"seed": {
"name": "seed"
},
"fixed": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"name": "seed",
"tooltip": null
}
}
},
"SelectCLIPDevice": {
"display_name": "Select CLIP Device",
"description": "Place the CLIP text encoder on a specific device (default / cpu / gpu:N).\n\n- \"default\" restores the device assigned by the loader.\n- \"cpu\" pins both the load and offload device to CPU.\n- \"gpu:N\" pins the load device to the Nth available GPU.\n\nWhen the selected device does not exist on the current machine\n(e.g. a workflow built on a 2-GPU box opened on a 1-GPU box),\nthe node passes the CLIP through unchanged and logs a message\ninstead of failing.",

View File

@@ -92,6 +92,7 @@
"errorUserTokenAccessDenied": "Tu token de API no tiene acceso a este recurso. Por favor, revisa los permisos de tu token.",
"errorUserTokenInvalid": "Tu token de API almacenado no es válido o ha expirado. Por favor, actualiza tu token en la configuración.",
"failedToCreateNode": "No se pudo crear el nodo. Inténtalo de nuevo o revisa la consola para más detalles.",
"failedToSetModelValue": "Nodo añadido, pero su modelo no pudo establecerse automáticamente. Consulta la consola para más detalles.",
"fileFormats": "Formatos de archivo",
"fileName": "Nombre del archivo",
"fileSize": "Tamaño del archivo",
@@ -241,7 +242,8 @@
"auth/user-not-found": "No se encontró ninguna cuenta con este correo electrónico. ¿Te gustaría crear una nueva cuenta?",
"auth/weak-password": "La contraseña es demasiado débil. Por favor, usa una contraseña más segura con al menos 6 caracteres.",
"auth/wrong-password": "La contraseña que ingresaste es incorrecta. Por favor, inténtalo de nuevo.",
"generic": "Ocurrió un error al iniciar sesión. Por favor, inténtalo de nuevo."
"generic": "Ocurrió un error al iniciar sesión. Por favor, inténtalo de nuevo.",
"signupBlocked": "No pudimos crear tu cuenta en este momento. Por favor, inténtalo de nuevo más tarde. Si el problema persiste, escribe a support@comfy.org."
},
"login": {
"andText": "y",
@@ -323,6 +325,11 @@
"signUpWithGithub": "Registrarse con Github",
"signUpWithGoogle": "Registrarse con Google",
"title": "Crea una cuenta"
},
"turnstile": {
"expired": "La verificación ha expirado. Por favor, completa el desafío nuevamente.",
"failed": "La verificación falló. Por favor, inténtalo de nuevo.",
"submitBlockedHint": "Completa el desafío de verificación arriba para habilitar el registro."
}
},
"batch": {
@@ -346,6 +353,17 @@
"x": "X",
"y": "Y"
},
"boundingBoxes": {
"clearAll": "Borrar todo",
"clickRegionToEdit": "Haz clic en una región para editarla.",
"colors": "paleta_de_colores",
"descLabel": "descripción",
"descPlaceholder": "descripción de esta región",
"textLabel": "Texto",
"textPlaceholder": "texto a renderizar (literal)",
"typeObj": "obj",
"typeText": "texto"
},
"breadcrumbsMenu": {
"app": "Aplicación",
"blueprint": "Plano",
@@ -755,6 +773,13 @@
"creditsAvailable": "Créditos disponibles",
"details": "Detalles",
"eventType": "Tipo de evento",
"eventTypes": {
"accountCreated": "Cuenta creada",
"apiNodeUsage": "Uso de nodo asociado",
"apiUsage": "Uso de API",
"creditAdded": "Créditos añadidos",
"gpuUsage": "Uso de GPU"
},
"faqs": "Preguntas frecuentes",
"invoiceHistory": "Historial de facturas",
"lastUpdated": "Última actualización",
@@ -811,6 +836,7 @@
},
"dataTypes": {
"*": "*",
"ARRAY": "ARRAY",
"AUDIO": "AUDIO",
"AUDIO_ENCODER": "CODIFICADOR_AUDIO",
"AUDIO_ENCODER_OUTPUT": "SALIDA_CODIFICADOR_AUDIO",
@@ -818,11 +844,13 @@
"BACKGROUND_REMOVAL": "ELIMINACIÓN_DE_FONDO",
"BOOLEAN": "BOOLEANO",
"BOUNDING_BOX": "CUADRO DELIMITADOR",
"BOUNDING_BOXES": "CUADROS DELIMITADORES",
"CAMERA_CONTROL": "CONTROL DE CÁMARA",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "SALIDA_CLIP_VISION",
"COLOR": "COLOR",
"COLORS": "COLORES",
"COMBO": "COMBO",
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
@@ -832,6 +860,7 @@
"CURVE": "CURVA",
"DA3_GEOMETRY": "DA3_GEOMETRY",
"DA3_MODEL": "DA3_MODEL",
"DICT": "DICCIONARIO",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "MODELO_DE_DETECCIÓN_DE_CARAS",
"FACE_LANDMARKS": "FACE_LANDMARKS",
@@ -1830,6 +1859,35 @@
"zoomOptions": "Opciones de Zoom",
"zoomOut": "Alejar"
},
"hdrViewer": {
"channel": "Canal",
"channels": {
"a": "Alfa",
"b": "B",
"g": "G",
"luminance": "Luminancia",
"r": "R",
"rgb": "RGB"
},
"clipWarnings": "Avisos de recorte",
"dither": "Difuminado",
"exposure": "Exposición",
"failedToLoad": "No se pudo cargar la imagen HDR",
"fitView": "Ajustar",
"hdrImage": "Imagen HDR",
"histogram": "Histograma",
"inf": "Inf",
"max": "Máx",
"mean": "Media",
"min": "Mín",
"nan": "NaN",
"normalizeExposure": "Exposición automática",
"openInHdrViewer": "Abrir en el visor HDR",
"resolution": "Resolución",
"sourceGamut": "Gama de origen",
"stdDev": "Desv. estándar",
"title": "Visor HDR"
},
"help": {
"helpCenterMenu": "Menú del Centro de Ayuda",
"recentReleases": "Lanzamientos recientes"
@@ -2957,6 +3015,10 @@
"uploadError": "Error al cargar la imagen del pintor: {status} - {statusText}",
"width": "Ancho"
},
"palette": {
"addColor": "Agregar un color",
"swatchTitle": "Haz clic para editar · arrastra para reordenar · clic derecho para eliminar"
},
"progressToast": {
"allDownloadsCompleted": "Todas las descargas completadas",
"downloadingModel": "Descargando modelo...",
@@ -3041,7 +3103,6 @@
"color": "Color del nodo",
"editSubgraph": "Editar subgrafo",
"editTitle": "Editar título",
"enterSubgraph": "Entrar en subgrafo",
"errorHelp": "Para más ayuda, {github} o {support}",
"errorHelpGithub": "envía un issue en GitHub",
"errorHelpSupport": "contacta con nuestro soporte",
@@ -3661,6 +3722,10 @@
"addApiCredits": "Agregar créditos de API",
"addCredits": "Agregar créditos",
"addCreditsLabel": "Agrega más créditos cuando quieras",
"additionalCredits": "Créditos adicionales",
"additionalCreditsInUse": "En uso",
"additionalCreditsInfo": "Acerca de los créditos adicionales",
"additionalCreditsTooltip": "Créditos que agregas además de tu plan. Se usan después de agotar los créditos mensuales. Cada uno expira un año después de la compra.",
"benefits": {
"benefit1": "Créditos mensuales para Nodos de Socio — recarga cuando sea necesario",
"benefit1FreeTier": "Más créditos mensuales, recarga en cualquier momento",
@@ -3682,27 +3747,55 @@
"keepSubscription": "Mantener suscripción",
"title": "Cancelar suscripción"
},
"cancelSubscription": "Cancelar suscripción",
"cancelPlan": "Cancelar plan",
"cancelSuccess": "Suscripción cancelada correctamente",
"canceled": "Cancelada",
"canceledCard": {
"description": "No se te cobrará de nuevo. Tus funciones seguirán activas hasta {date}.",
"title": "Tu suscripción ha sido cancelada"
},
"changePlan": "Cambiar plan",
"changeTo": "Cambiar a {plan}",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo de Comfy Cloud",
"contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte",
"contactUs": "Contáctanos",
"creditSliderSave": "Ahorra {percent}% ({amount})",
"creditsLeftOfTotal": "{remaining} de {total} restantes",
"creditsRemainingThisMonth": "Créditos restantes este mes",
"creditsRemainingThisYear": "Créditos restantes este año",
"creditsUsed": "{used} usados",
"creditsYouveAdded": "Créditos que has agregado",
"currentPlan": "Plan actual",
"customLoRAsLabel": "Importa tus propios LoRAs",
"description": "Elige el mejor plan para ti",
"descriptionWorkspace": "Elige el mejor plan para tu espacio de trabajo",
"downgrade": {
"body": "Todos los demás miembros de este espacio de trabajo serán eliminados inmediatamente.",
"confirm": "Cambiar plan",
"confirmationPhrase": "Entiendo",
"confirmationPrompt": "Escribe \"{phrase}\" para confirmar.",
"failed": "No se pudo cambiar el plan",
"failedAfterMemberRemoval": "Los miembros del equipo fueron eliminados, pero el cambio de plan no se completó — por favor, inténtalo de nuevo o contacta con soporte",
"memberRemovalFailed": "No se pudo eliminar a {email} del equipo — algunos miembros pueden haber sido eliminados y tu plan no fue cambiado",
"notAllowed": "Este cambio de plan no está disponible",
"paymentMethodRequired": "Se requiere un método de pago para cambiar de plan",
"paymentPageBlocked": "No se pudo abrir la página de pago — por favor, inténtalo de nuevo",
"title": "¿Cambiar al plan {plan}?"
},
"endsOnDate": "Finaliza el {date}",
"enterprise": {
"cta": "Saber más",
"flexibility": "¿Buscas más flexibilidad o funciones personalizadas?",
"name": "Enterprise",
"needMoreMembers": "¿Necesitas más miembros?",
"reachOut": "Contáctanos y agendemos una charla."
},
"everythingInPlus": "Todo en {plan}, además:",
"expiresDate": "Caduca el {date}",
"freePerks": {
"maxRuntime": "{duration} de tiempo máximo de ejecución"
},
"freeTier": {
"description": "Tu plan gratuito incluye {credits} créditos cada mes para probar Comfy Cloud.",
"descriptionGeneric": "Tu plan gratuito incluye una asignación mensual de créditos para probar Comfy Cloud.",
@@ -3730,7 +3823,7 @@
"inviteUpTo": "Invita hasta",
"invoiceHistory": "Historial de facturas",
"learnMore": "Más información",
"managePayment": "Gestionar pago",
"manageBilling": "Gestionar facturación",
"managePlan": "Gestionar plan",
"manageSubscription": "Gestionar suscripción",
"maxDuration": {
@@ -3744,50 +3837,99 @@
"maxMembersLabel": "Máx. miembros",
"member": "miembro",
"memberCount": "{count} miembro | {count} miembros",
"membersLabel": "Hasta {count} miembros",
"messageSupport": "Contactar con soporte",
"monthly": "Mensual",
"monthlyBonusDescription": "Bono de créditos mensual",
"monthlyCredits": "créditos mensuales",
"monthlyCreditsInfo": "Estos créditos se renuevan mensualmente y no se acumulan",
"monthlyCreditsLabel": "Créditos mensuales",
"monthlyCreditsPerMemberLabel": "Créditos mensuales / miembro",
"monthlyCreditsRollover": "Estos créditos se transferirán al próximo mes",
"monthlyCreditsUsedUpDescription": "Ahora estás usando créditos adicionales.",
"monthlyCreditsUsedUpTitle": "Los créditos mensuales se han agotado. Recargas {date}",
"monthlyCreditsUsedUpTitleNoDate": "Los créditos mensuales se han agotado",
"monthlyUsageProgress": "{used} de {total} créditos mensuales usados",
"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",
"outOfCreditsDescription": "Agrega más créditos para continuar generando.",
"outOfCreditsTitle": "Te has quedado sin créditos. Recarga de créditos {date}",
"outOfCreditsTitleNoDate": "Te has quedado sin créditos",
"partnerNodesBalance": "Saldo de créditos de \"Nodos de Partners\"",
"partnerNodesCredits": "Créditos de Nodos de Socio",
"partnerNodesDescription": "Para ejecutar modelos comerciales/propietarios",
"partnerNodesPricingTable": "Tabla de precios de Partner Nodes",
"perMonth": "USD / mes",
"personalHeader": "Los planes personales son solo para uso individual. {action}",
"personalHeaderAction": "Para agregar compañeros, suscríbete al plan de equipo.",
"personalWorkspace": "Espacio de trabajo personal",
"planLoadError": "No pudimos cargar los detalles de tu plan.",
"planLoadErrorRetry": "Intentar de nuevo",
"planScope": {
"personal": "Para uso personal",
"team": "Para equipos"
},
"plansAndPricing": "Planes y precios",
"plansForWorkspace": "Planes para {workspace}",
"prepaidCreditsInfo": "Créditos comprados por separado que no expiran",
"prepaidDescription": "Créditos prepagados",
"preview": {
"addCreditCard": "Agregar tarjeta de crédito",
"afterThat": "Después de eso",
"backToAllPlans": "Volver a todos los planes",
"billedEachMonth": "{amount} facturado cada mes. Cancela en cualquier momento.",
"commitment": "compromiso",
"confirm": "Confirmar",
"confirmChange": "Confirmar cambio",
"confirmChangeTitle": "Revisa tu cambio programado",
"confirmPayment": "Confirma tu pago",
"confirmPlanChange": "Confirma el cambio de plan",
"confirmUpgradeCta": "Confirmar actualización",
"confirmUpgradeTitle": "Confirma tu actualización",
"creditFromCurrent": "Crédito del {plan} actual",
"creditsRefillMonthlyTo": "Los créditos se recargan mensualmente a",
"creditsRefillTo": "Los créditos se recargan hasta",
"creditsYoullGetToday": "Créditos que recibirás hoy",
"currentMonthly": "plan mensual",
"eachMonthCreditsRefill": "Cada mes los créditos se recargan a",
"eachYearCreditsRefill": "Cada año los créditos se recargan a",
"ends": "Finaliza el {date}",
"everyMonthStarting": "Cada mes a partir del {date}",
"hideFeatures": "Ocultar funciones",
"newMonthlySubscription": "Nueva suscripción mensual",
"nextPaymentDue": "Próximo pago el {date}. Cancela en cualquier momento.",
"paymentPopupBlocked": "No se pudo abrir la página de pago — permite las ventanas emergentes e inténtalo de nuevo.",
"perMember": "/ miembro",
"privacyPolicy": "Política de privacidad",
"proratedCharge": "Cargo prorrateado por {plan}",
"proratedRefund": "Reembolso prorrateado por {plan}",
"refillReplacesNote": "Reemplaza tu recarga mensual. El saldo existente se mantiene.",
"showMoreFeatures": "Mostrar más funciones",
"starting": "Comienza el {date}",
"startingToday": "Comienza hoy",
"startsOn": "Comienza el {date}",
"stayOnUntil": "Permanecerás en {plan} hasta el {date}.",
"subscribeToPlan": "Suscribirse al plan {plan}",
"switchToPlan": "Cambiar al plan {plan}",
"switchesToday": "Cambios hoy",
"terms": "Términos",
"termsAgreement": "Al continuar, aceptas los {terms} y la {privacy} de Comfy Org.",
"totalDueToday": "Total a pagar hoy"
"totalDueToday": "Total a pagar hoy",
"yearlySubscription": "Suscripción anual",
"youllBeCharged": "Se te cobrará"
},
"pricingBlurb": "*Basado en esta plantilla, {seeDetails}. Contáctanos para {questions} o {enterpriseDiscussions}. Para más detalles sobre precios, {clickHere}.",
"pricingBlurbClickHere": "haz clic aquí",
"pricingBlurbEnterprise": "discusiones enterprise",
"pricingBlurbQuestions": "preguntas",
"pricingBlurbSeeDetails": "ver detalles",
"reactivatePlan": "Reactivar plan",
"refillsDate": "Recargas {date}",
"refillsNextCycle": "Recargas en el próximo ciclo",
"refreshCredits": "Actualizar créditos",
"remaining": "restante",
"renewsDate": "Se renueva el {date}",
"renewsOnDate": "Renueva el {date}",
"required": {
"pollingFailed": "Error al activar la suscripción",
"pollingSuccess": "¡Suscripción activada correctamente!",
@@ -3799,7 +3941,11 @@
"resubscribe": "Volver a suscribirse",
"resubscribeSuccess": "¡Suscripción reactivada correctamente!",
"resubscribeTo": "Volver a suscribirse a {plan}",
"saveYearly": "Ahorra 20%",
"saveYearlyUpTo": "Ahorra hasta un 20%",
"soloUseOnly": "Solo para uso individual",
"subscribe": "Suscribirse",
"subscribeFailed": "No se pudo suscribir",
"subscribeForMore": "Mejorar",
"subscribeNow": "Suscribirse Ahora",
"subscribeTo": "Suscribirse a {plan}",
@@ -3807,10 +3953,46 @@
"subscribeToRun": "Suscribirse",
"subscribeToRunFull": "Suscribirse a Ejecutar",
"subscriptionRequiredMessage": "Se requiere una suscripción para que los miembros ejecuten flujos de trabajo en la nube",
"success": {
"allSet": "Todo listo",
"inviteEmailsPlaceholder": "Introduce correos electrónicos separados por comas",
"inviteSubtext": "También puedes invitar personas más tarde desde Configuración",
"inviteTitle": "Invita a tu equipo",
"planUpdated": "Tu plan se ha actualizado correctamente.",
"receiptEmailed": "Se ha enviado un recibo a tu correo electrónico.",
"sendInvites": "Enviar invitaciones"
},
"teamHeader": "Para equipos que desean colaborar. ¿Necesitas más miembros? {learnMore} sobre enterprise.",
"teamHeaderLearnMore": "Saber más",
"teamPerks": {
"concurrentRuns": "Los miembros pueden ejecutar flujos de trabajo simultáneamente",
"inviteMembers": "Invitar miembros",
"rolePermissions": "Permisos basados en roles",
"sharedCreditPool": "Bolsa de créditos compartida para todos los miembros"
},
"teamPlan": {
"changePlan": "Cambiar plan",
"comingSoonLabel": "Próximamente:",
"cta": "Suscribirse al plan anual de equipo",
"ctaMonthly": "Suscribirse al plan mensual de equipo",
"currentPlan": "Plan actual",
"detailsTitle": "Detalles",
"name": "Plan de equipo",
"perkConcurrentRuns": "Los miembros pueden ejecutar flujos de trabajo simultáneamente",
"perkInviteMembers": "Invita a miembros del equipo",
"perkProjectAssets": "Gestión de proyectos y recursos",
"perkRolePermissions": "Permisos basados en roles",
"perkSharedPool": "Bolsa de créditos compartida para todos los miembros",
"tagline": "Elige tu propia suscripción mensual de créditos. Obtén un mayor descuento con una suscripción de más créditos.",
"unavailable": "Este plan de equipo no está disponible en este momento."
},
"teamPlanIncludes": "Tu plan incluye todo en {plan}, además de:",
"teamPlanName": "Equipo",
"teamWorkspace": "Espacio de trabajo en equipo",
"tierNameYearly": "{name} Anual",
"tiers": {
"creator": {
"feature1": "Importa tus propios modelos",
"name": "Creador"
},
"founder": {
@@ -3820,9 +4002,12 @@
"name": "Gratis"
},
"pro": {
"feature1": "Mayor tiempo de ejecución del flujo de trabajo (hasta 1 hora)",
"name": "Pro"
},
"standard": {
"feature1": "Tiempo máximo de ejecución del flujo de trabajo de 30 minutos",
"feature2": "Agrega más créditos en cualquier momento",
"name": "Estándar"
}
},
@@ -3835,6 +4020,8 @@
"upgradeToAddCredits": "Mejorar para añadir créditos",
"usdPerMonth": "USD / mes",
"usdPerMonthPerMember": "USD / mes / miembro",
"usedAfterMonthly": "Usados después de agotar los mensuales",
"videoEstimate": "Genera ~{count} videos de 5s*",
"videoEstimateExplanation": "Estas estimaciones se basan en la plantilla Wan 2.2 Imagen a Video usando la configuración predeterminada (5 segundos, 640x640, 16fps, muestreo de 4 pasos).",
"videoEstimateHelp": "Más detalles sobre esta plantilla",
"videoEstimateLabel": "Cantidad aprox. de videos de 5s generados con la plantilla Wan 2.2 Imagen a Video",
@@ -3844,10 +4031,10 @@
"viewMoreDetails": "Ver más detalles",
"viewMoreDetailsPlans": "Ver más detalles sobre planes y precios",
"viewUsageHistory": "Ver historial de uso",
"whatsIncluded": "Qué está incluido:",
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuales",
"yearlyDiscount": "20% DESCUENTO",
"yourPlanIncludes": "Tu plan incluye:"
},
"tabMenu": {
@@ -4138,6 +4325,19 @@
}
},
"workspacePanel": {
"changeRoleDialog": {
"demoteConfirm": "Degradar a miembro",
"demoteMessage": "Perderá acceso de administrador.",
"demoteTitle": "¿Degradar a {name} a miembro?",
"error": "No se pudo actualizar el rol",
"promoteConfirm": "Hacer propietario",
"promoteIntro": "Podrá:",
"promotePermissionCredits": "Agregar créditos adicionales",
"promotePermissionManage": "Gestionar miembros, métodos de pago y la configuración del espacio de trabajo",
"promotePermissionRoles": "Promocionar y degradar a otros propietarios (excepto al creador del espacio de trabajo).",
"promoteTitle": "¿Hacer que {name} sea propietario?",
"success": "Rol actualizado"
},
"createWorkspaceDialog": {
"create": "Crear",
"message": "Los espacios de trabajo permiten a los miembros compartir un único fondo de créditos. Te convertirás en el propietario después de crearlo.",
@@ -4162,17 +4362,11 @@
"inviteLimitReached": "Has alcanzado el máximo de 50 miembros",
"inviteMember": "Invitar miembro",
"inviteMemberDialog": {
"createLink": "Crear enlace",
"linkCopied": "Copiado",
"linkCopyFailed": "No se pudo copiar el enlace",
"linkStep": {
"copyLink": "Copiar enlace",
"done": "Listo",
"message": "Asegúrate de que su cuenta use este correo electrónico.",
"title": "Envía este enlace a la persona"
},
"message": "Crea un enlace de invitación para compartir y envíalo a alguien",
"failedCount": "No se pudo enviar {count} invitación. Intenta de nuevo. | No se pudieron enviar {count} invitaciones. Intenta de nuevo.",
"invalidEmailCount": "{count} dirección de correo electrónico no válida | {count} direcciones de correo electrónico no válidas",
"invitedMessage": "Se envió una invitación a {emails} | Se enviaron invitaciones a {emails}",
"placeholder": "Introduce el correo electrónico de la persona",
"seatLimitReached": "Puedes invitar hasta {count} compañero de equipo. | Puedes invitar hasta {count} compañeros de equipo.",
"title": "Invitar a una persona a este espacio de trabajo"
},
"inviteUpsellDialog": {
@@ -4180,8 +4374,7 @@
"messageSingleSeat": "El plan Standard incluye un asiento para el propietario del espacio de trabajo. Para invitar a miembros adicionales, actualiza al plan Creator o superior para desbloquear múltiples asientos.",
"titleNotSubscribed": "Se requiere una suscripción para invitar miembros",
"titleSingleSeat": "Tu plan actual admite un solo usuario",
"upgradeToCreator": "Actualizar a Creator",
"viewPlans": "Ver planes"
"upgradeToTeam": "Actualizar a Team"
},
"leaveDialog": {
"leave": "Abandonar",
@@ -4190,30 +4383,35 @@
},
"members": {
"actions": {
"copyLink": "Copiar enlace de invitación",
"cancelInvite": "Cancelar invitación",
"changeRole": "Cambiar rol",
"removeMember": "Eliminar miembro",
"revokeInvite": "Revocar invitación"
"resendInvite": "Reenviar invitación"
},
"columns": {
"expiryDate": "Fecha de vencimiento",
"inviteDate": "Fecha de invitación",
"joinDate": "Fecha de ingreso"
"role": "Rol"
},
"createNewWorkspace": "crea uno nuevo.",
"contactUs": "Contáctanos",
"header": "Miembros",
"membersCount": "{count}/50 Miembros",
"needMoreMembers": "¿Necesitas más miembros?",
"noInvites": "No hay invitaciones pendientes",
"noMembers": "No hay miembros",
"pendingInvitesCount": "{count} invitación pendiente | {count} invitaciones pendientes",
"personalWorkspaceMessage": "No puedes invitar a otros miembros a tu espacio de trabajo personal en este momento. Para agregar miembros a un espacio de trabajo,",
"reactivateTeam": "Reactivar Team",
"searchPlaceholder": "Buscar...",
"tabs": {
"active": "Activo",
"pendingCount": "Pendientes ({count})"
},
"upsellBannerSubscribe": "Suscríbete al plan Creator o superior para invitar a miembros del equipo a este espacio de trabajo.",
"upsellBannerUpgrade": "Actualiza al plan Creator o superior para invitar a miembros adicionales al equipo.",
"viewPlans": "Ver planes"
"upgradeToTeam": "Actualizar a Team",
"upsellBanner": "Para agregar compañeros de equipo, actualiza tu plan.",
"upsellBannerReactivate": "Para agregar más compañeros de equipo, reactiva tu plan."
},
"menu": {
"creatorCannotLeave": "El creador del espacio de trabajo no puede abandonar el espacio que creó",
"deleteWorkspace": "Eliminar espacio de trabajo",
"deleteWorkspaceDisabledTooltip": "Primero cancela la suscripción activa de tu espacio de trabajo",
"editWorkspace": "Editar detalles del espacio de trabajo",
@@ -4242,6 +4440,8 @@
"failedToFetchWorkspaces": "No se pudieron cargar los espacios de trabajo",
"failedToLeaveWorkspace": "No se pudo abandonar el espacio de trabajo",
"failedToUpdateWorkspace": "No se pudo actualizar el espacio de trabajo",
"inviteResendFailed": "No se pudo reenviar la invitación",
"inviteResent": "Invitación reenviada",
"workspaceCreated": {
"message": "Suscríbete a un plan, invita a compañeros y comienza a colaborar.",
"subscribe": "Suscribirse",

View File

@@ -730,6 +730,49 @@
}
}
},
"BuildJsonPromptIdeogram": {
"description": "Construye un prompt JSON para el modelo Ideogram 4.",
"display_name": "Construir prompt JSON (Ideogram)",
"inputs": {
"aesthetics": {
"name": "estética",
"tooltip": "Palabras clave estéticas obligatorias (ej. melancólico, cinematográfico, desaturado)."
},
"background": {
"name": "fondo",
"tooltip": "Descripción obligatoria del fondo o entorno de la imagen."
},
"color_palette": {
"name": "paleta_de_colores",
"tooltip": "Códigos de color hexadecimales que guían los colores dominantes de la imagen. Hasta 16 entradas."
},
"element": {
"name": "elemento",
"tooltip": "Elementos del prompt desde el nodo Crear Cajas Delimitadoras."
},
"high_level_description": {
"name": "descripción_general",
"tooltip": "Descripción opcional de la imagen en una o dos frases. Altamente recomendado."
},
"lighting": {
"name": "iluminación",
"tooltip": "Descripción obligatoria de la iluminación (ej. hora dorada, luz de contorno, sombras dramáticas)."
},
"medium": {
"name": "medio",
"tooltip": "Tipo de medio obligatorio (ej. fotografía, ilustración, 3d_render, pintura, diseño_gráfico). Cuando estilo = foto, establecer en fotografía."
},
"style": {
"name": "estilo"
}
},
"outputs": {
"0": {
"name": "prompt",
"tooltip": null
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "Genera un video usando Seedance 2.0 a partir de una imagen del primer fotograma y, opcionalmente, una imagen del último fotograma.",
"display_name": "ByteDance Seedance 2.0 Primer-Último Fotograma a Video",
@@ -2633,6 +2676,40 @@
}
}
},
"ConvertArrayToString": {
"display_name": "Convertir Array a Cadena",
"inputs": {
"array": {
"name": "array"
},
"indent": {
"name": "sangría",
"tooltip": "Espacios por nivel de sangría. 0 produce una cadena compacta en una sola línea."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ConvertDictionaryToString": {
"display_name": "Convertir Diccionario a Cadena",
"inputs": {
"dictionary": {
"name": "diccionario"
},
"indent": {
"name": "sangría",
"tooltip": "Espacios por nivel de sangría. 0 produce una cadena compacta en una sola línea."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"CosmosImageToVideoLatent": {
"display_name": "CosmosImageToVideoLatent",
"inputs": {
@@ -2695,6 +2772,42 @@
}
}
},
"CreateBoundingBoxes": {
"description": "Dibuja cajas delimitadoras en un lienzo. Genera elementos de prompt Ideogram, cajas delimitadoras en espacio de píxeles y una imagen de vista previa.",
"display_name": "Crear Cajas Delimitadoras",
"inputs": {
"background": {
"name": "fondo",
"tooltip": "Imagen opcional utilizada como fondo en el lienzo y la vista previa."
},
"editor_state": {
"name": "estado_del_editor",
"tooltip": "Dibuja cajas delimitadoras y asigna a cada caja su tipo, texto, descripción y paleta de colores. Comienza con el elemento de fondo y termina con el primer plano."
},
"height": {
"name": "alto",
"tooltip": "Alto del lienzo y de la cuadrícula de píxeles para las cajas delimitadoras."
},
"width": {
"name": "ancho",
"tooltip": "Ancho del lienzo y de la cuadrícula de píxeles para las cajas delimitadoras."
}
},
"outputs": {
"0": {
"name": "vista_previa",
"tooltip": null
},
"1": {
"name": "cajas_delimitadoras",
"tooltip": null
},
"2": {
"name": "elementos",
"tooltip": null
}
}
},
"CreateCameraInfo": {
"description": "Construye una camera_info. El modo 'orbit' apunta con yaw/pitch/distancia alrededor del objetivo; 'look_at' coloca la cámara en una posición del mundo. Las coordenadas están en el espacio mundial del espectador (mano derecha, Y-arriba).",
"display_name": "Crear información de cámara",
@@ -12025,6 +12138,131 @@
}
}
},
"ModelMergeKrea2": {
"display_name": "ModelMergeKrea2",
"inputs": {
"blocks_0_": {
"name": "bloques.0."
},
"blocks_10_": {
"name": "bloques.10."
},
"blocks_11_": {
"name": "bloques.11."
},
"blocks_12_": {
"name": "bloques.12."
},
"blocks_13_": {
"name": "bloques.13."
},
"blocks_14_": {
"name": "bloques.14."
},
"blocks_15_": {
"name": "bloques.15."
},
"blocks_16_": {
"name": "bloques.16."
},
"blocks_17_": {
"name": "bloques.17."
},
"blocks_18_": {
"name": "bloques.18."
},
"blocks_19_": {
"name": "bloques.19."
},
"blocks_1_": {
"name": "bloques.1."
},
"blocks_20_": {
"name": "bloques.20."
},
"blocks_21_": {
"name": "bloques.21."
},
"blocks_22_": {
"name": "bloques.22."
},
"blocks_23_": {
"name": "bloques.23."
},
"blocks_24_": {
"name": "bloques.24."
},
"blocks_25_": {
"name": "bloques.25."
},
"blocks_26_": {
"name": "bloques.26."
},
"blocks_27_": {
"name": "bloques.27."
},
"blocks_2_": {
"name": "bloques.2."
},
"blocks_3_": {
"name": "bloques.3."
},
"blocks_4_": {
"name": "bloques.4."
},
"blocks_5_": {
"name": "bloques.5."
},
"blocks_6_": {
"name": "bloques.6."
},
"blocks_7_": {
"name": "bloques.7."
},
"blocks_8_": {
"name": "bloques.8."
},
"blocks_9_": {
"name": "bloques.9."
},
"first_": {
"name": "primero."
},
"last_": {
"name": "último."
},
"model1": {
"name": "model1"
},
"model2": {
"name": "model2"
},
"tmlp_": {
"name": "tmlp."
},
"tproj_": {
"name": "tproj."
},
"txtfusion_layerwise_blocks_0_": {
"name": "txtfusion.layerwise_blocks.0."
},
"txtfusion_layerwise_blocks_1_": {
"name": "txtfusion.layerwise_blocks.1."
},
"txtfusion_projector_": {
"name": "txtfusion.projector."
},
"txtfusion_refiner_blocks_0_": {
"name": "txtfusion.refiner_blocks.0."
},
"txtfusion_refiner_blocks_1_": {
"name": "txtfusion.refiner_blocks.1."
},
"txtmlp_": {
"name": "txtmlp."
}
}
},
"ModelMergeLTXV": {
"display_name": "ModelMergeLTXV",
"inputs": {
@@ -17698,6 +17936,23 @@
}
}
},
"SeedNode": {
"display_name": "Semilla",
"inputs": {
"fixed": {
"name": "controlar después de generar"
},
"seed": {
"name": "semilla"
}
},
"outputs": {
"0": {
"name": "semilla",
"tooltip": null
}
}
},
"SelectCLIPDevice": {
"description": "Coloca el codificador de texto CLIP en un dispositivo específico (default / cpu / gpu:N).\n\n- \"default\" restaura el dispositivo asignado por el cargador.\n- \"cpu\" fija tanto la carga como la descarga en la CPU.\n- \"gpu:N\" fija el dispositivo de carga en la N-ésima GPU disponible.\n\nCuando el dispositivo seleccionado no existe en la máquina actual\n(por ejemplo, un flujo de trabajo creado en una máquina con 2 GPU abierto en una con 1 GPU),\nel nodo pasa el CLIP sin cambios y registra un mensaje\nen lugar de fallar.",
"display_name": "Seleccionar Dispositivo CLIP",

View File

@@ -92,6 +92,7 @@
"errorUserTokenAccessDenied": "توکن API شما به این منبع دسترسی ندارد. لطفاً مجوزهای توکن خود را بررسی کنید.",
"errorUserTokenInvalid": "توکن API ذخیره‌شده شما نامعتبر یا منقضی شده است. لطفاً توکن خود را در تنظیمات به‌روزرسانی کنید.",
"failedToCreateNode": "ایجاد node ناموفق بود. لطفاً دوباره تلاش کنید یا کنسول را بررسی کنید.",
"failedToSetModelValue": "Node اضافه شد، اما مدل آن به‌صورت خودکار تنظیم نشد. برای جزئیات بیشتر کنسول را بررسی کنید.",
"fileFormats": "فرمت‌های فایل",
"fileName": "نام فایل",
"fileSize": "اندازه فایل",
@@ -241,7 +242,8 @@
"auth/user-not-found": "حسابی با این ایمیل یافت نشد. مایل به ایجاد حساب جدید هستید؟",
"auth/weak-password": "رمز عبور خیلی ضعیف است. لطفاً از رمز عبور قوی‌تر با حداقل ۶ کاراکتر استفاده کنید.",
"auth/wrong-password": "رمز عبور وارد شده نادرست است. لطفاً دوباره تلاش کنید.",
"generic": "در ورود شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید."
"generic": "در ورود شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"signupBlocked": "در حال حاضر امکان ایجاد حساب کاربری وجود ندارد. لطفاً بعداً دوباره تلاش کنید. اگر این مشکل ادامه داشت، با support@comfy.org تماس بگیرید."
},
"login": {
"andText": "و",
@@ -323,6 +325,11 @@
"signUpWithGithub": "ثبت‌نام با Github",
"signUpWithGoogle": "ثبت‌نام با Google",
"title": "ایجاد حساب کاربری"
},
"turnstile": {
"expired": "اعتبار تأییدیه به پایان رسیده است. لطفاً دوباره چالش را تکمیل کنید.",
"failed": "تأییدیه ناموفق بود. لطفاً دوباره تلاش کنید.",
"submitBlockedHint": "برای فعال‌سازی ثبت‌نام، ابتدا چالش تأییدیه بالا را تکمیل کنید."
}
},
"batch": {
@@ -346,6 +353,17 @@
"x": "ایکس",
"y": "وای"
},
"boundingBoxes": {
"clearAll": "پاک‌سازی همه",
"clickRegionToEdit": "برای ویرایش، روی یک ناحیه کلیک کنید.",
"colors": "پالت رنگ",
"descLabel": "توضیحات",
"descPlaceholder": "توضیحات این ناحیه",
"textLabel": "متن",
"textPlaceholder": "متن برای نمایش (عیناً)",
"typeObj": "obj",
"typeText": "متن"
},
"breadcrumbsMenu": {
"app": "برنامه",
"blueprint": "نقشه راه",
@@ -755,6 +773,13 @@
"creditsAvailable": "اعتبار موجود",
"details": "جزئیات",
"eventType": "نوع رویداد",
"eventTypes": {
"accountCreated": "حساب کاربری ایجاد شد",
"apiNodeUsage": "استفاده از Node شریک",
"apiUsage": "استفاده از API",
"creditAdded": "اعتبار افزوده شد",
"gpuUsage": "استفاده از GPU"
},
"faqs": "سؤالات متداول",
"invoiceHistory": "تاریخچه فاکتورها",
"lastUpdated": "آخرین به‌روزرسانی",
@@ -811,6 +836,7 @@
},
"dataTypes": {
"*": "*",
"ARRAY": "آرایه",
"AUDIO": "صوت",
"AUDIO_ENCODER": "رمزگذار صوت",
"AUDIO_ENCODER_OUTPUT": "خروجی رمزگذار صوت",
@@ -818,11 +844,13 @@
"BACKGROUND_REMOVAL": "حذف پس‌زمینه",
"BOOLEAN": "بولی",
"BOUNDING_BOX": "BOUNDING_BOX",
"BOUNDING_BOXES": "جعبه‌های مرزی",
"CAMERA_CONTROL": "کنترل دوربین",
"CLIP": "clip",
"CLIP_VISION": "بینایی clip",
"CLIP_VISION_OUTPUT": "خروجی بینایی clip",
"COLOR": "رنگ",
"COLORS": "رنگ‌ها",
"COMBO": "ترکیبی",
"COMFY_AUTOGROW_V3": "Comfy AutoGrow V3",
"COMFY_DYNAMICCOMBO_V3": "Comfy DynamicCombo V3",
@@ -832,6 +860,7 @@
"CURVE": "CURVE",
"DA3_GEOMETRY": "DA3_GEOMETRY",
"DA3_MODEL": "DA3_MODEL",
"DICT": "دیکشنری",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "مدل تشخیص چهره",
"FACE_LANDMARKS": "FACE_LANDMARKS",
@@ -1830,6 +1859,35 @@
"zoomOptions": "گزینه‌های بزرگ‌نمایی",
"zoomOut": "کوچک‌نمایی"
},
"hdrViewer": {
"channel": "کانال",
"channels": {
"a": "آلفا",
"b": "B",
"g": "G",
"luminance": "درخشندگی",
"r": "R",
"rgb": "RGB"
},
"clipWarnings": "هشدار برش",
"dither": "دیتر",
"exposure": "نوردهی",
"failedToLoad": "بارگذاری تصویر HDR ناموفق بود",
"fitView": "متناسب‌سازی",
"hdrImage": "تصویر HDR",
"histogram": "هیستوگرام",
"inf": "بی‌نهایت",
"max": "بیشینه",
"mean": "میانگین",
"min": "کمینه",
"nan": "NaN",
"normalizeExposure": "نوردهی خودکار",
"openInHdrViewer": "باز کردن در نمایشگر HDR",
"resolution": "وضوح",
"sourceGamut": "گاموت منبع",
"stdDev": "انحراف معیار",
"title": "نمایشگر HDR"
},
"help": {
"helpCenterMenu": "منوی مرکز راهنما",
"recentReleases": "انتشارهای اخیر"
@@ -2957,6 +3015,10 @@
"uploadError": "بارگذاری تصویر painter ناموفق بود: {status} - {statusText}",
"width": "عرض"
},
"palette": {
"addColor": "افزودن رنگ",
"swatchTitle": "برای ویرایش کلیک کنید · برای جابجایی بکشید · برای حذف راست‌کلیک کنید"
},
"progressToast": {
"allDownloadsCompleted": "همه دانلودها تکمیل شدند",
"downloadingModel": "در حال دانلود مدل...",
@@ -3041,7 +3103,6 @@
"color": "رنگ نود",
"editSubgraph": "ویرایش زیرگراف",
"editTitle": "ویرایش عنوان",
"enterSubgraph": "ورود به زیرگراف",
"errorHelp": "برای دریافت کمک بیشتر، {github} یا {support}",
"errorHelpGithub": "ثبت یک issue در GitHub",
"errorHelpSupport": "تماس با پشتیبانی ما",
@@ -3673,6 +3734,10 @@
"addApiCredits": "افزودن اعتبار API",
"addCredits": "افزودن اعتبار",
"addCreditsLabel": "هر زمان اعتبار بیشتری اضافه کنید",
"additionalCredits": "اعتبارهای اضافی",
"additionalCreditsInUse": "در حال استفاده",
"additionalCreditsInfo": "درباره اعتبارهای اضافی",
"additionalCreditsTooltip": "اعتبارهایی که علاوه بر طرح خود اضافه می‌کنید. پس از اتمام اعتبار ماهانه مصرف می‌شوند. هر یک تا یک سال پس از خرید منقضی می‌شود.",
"benefits": {
"benefit1": "۱۰ دلار اعتبار ماهانه برای Partner Nodes — در صورت نیاز شارژ کنید",
"benefit1FreeTier": "اعتبار ماهانه بیشتر، شارژ مجدد در هر زمان",
@@ -3694,27 +3759,55 @@
"keepSubscription": "حفظ اشتراک",
"title": "لغو اشتراک"
},
"cancelSubscription": "لغو اشتراک",
"cancelPlan": "لغو پلن",
"cancelSuccess": "اشتراک با موفقیت لغو شد",
"canceled": "لغو شد",
"canceledCard": {
"description": "دیگر هزینه‌ای از شما دریافت نمی‌شود. امکانات شما تا تاریخ {date} فعال خواهد بود.",
"title": "اشتراک شما لغو شده است"
},
"changePlan": "تغییر پلن",
"changeTo": "تغییر به {plan}",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "لوگوی Comfy Cloud",
"contactOwnerToSubscribe": "برای فعال‌سازی اشتراک با مالک محیط کاری تماس بگیرید",
"contactUs": "تماس با ما",
"creditSliderSave": "ذخیره {percent}٪ ({amount})",
"creditsLeftOfTotal": "{remaining} از {total} باقی‌مانده",
"creditsRemainingThisMonth": "شامل شده (شارژ مجدد {date})",
"creditsRemainingThisYear": "شامل شده (شارژ مجدد {date})",
"creditsUsed": "{used} مصرف شده",
"creditsYouveAdded": "اضافه شده",
"currentPlan": "طرح فعلی",
"customLoRAsLabel": "LoRAهای خود را وارد کنید",
"description": "بهترین طرح را برای خود انتخاب کنید",
"descriptionWorkspace": "بهترین طرح را برای فضای کاری خود انتخاب کنید",
"downgrade": {
"body": "تمام اعضای دیگر این فضای کاری بلافاصله حذف خواهند شد.",
"confirm": "تغییر پلن",
"confirmationPhrase": "متوجه شدم",
"confirmationPrompt": "برای تأیید، عبارت «{phrase}» را وارد کنید.",
"failed": "تغییر پلن انجام نشد",
"failedAfterMemberRemoval": "اعضای تیم حذف شدند، اما تغییر پلن کامل نشد — لطفاً دوباره تلاش کنید یا با پشتیبانی تماس بگیرید",
"memberRemovalFailed": "امکان حذف {email} از تیم وجود ندارد — برخی اعضا ممکن است قبلاً حذف شده باشند و پلن شما تغییر نکرده است",
"notAllowed": "این تغییر پلن در دسترس نیست",
"paymentMethodRequired": "برای تغییر پلن، نیاز به روش پرداخت است",
"paymentPageBlocked": "امکان باز کردن صفحه پرداخت وجود ندارد — لطفاً دوباره تلاش کنید",
"title": "تغییر به پلن {plan}؟"
},
"endsOnDate": "پایان در تاریخ {date}",
"enterprise": {
"cta": "بیشتر بدانید",
"flexibility": "به دنبال امکانات یا انعطاف‌پذیری بیشتر هستید؟",
"name": "سازمانی",
"needMoreMembers": "به اعضای بیشتری نیاز دارید؟",
"reachOut": "با ما تماس بگیرید تا زمانی برای گفتگو هماهنگ کنیم."
},
"everythingInPlus": "همه امکانات {plan}، به‌علاوه:",
"expiresDate": "انقضا در {date}",
"freePerks": {
"maxRuntime": "حداکثر زمان اجرا {duration}"
},
"freeTier": {
"description": "طرح رایگان شما شامل {credits} اعتبار در هر ماه برای استفاده از Comfy Cloud است.",
"descriptionGeneric": "طرح رایگان شما شامل اعتبار ماهانه برای استفاده از Comfy Cloud است.",
@@ -3742,7 +3835,7 @@
"inviteUpTo": "دعوت تا سقف",
"invoiceHistory": "تاریخچه فاکتورها",
"learnMore": "اطلاعات بیشتر",
"managePayment": "مدیریت پرداخت",
"manageBilling": "مدیریت صورتحساب",
"managePlan": "مدیریت طرح",
"manageSubscription": "مدیریت اشتراک",
"maxDuration": {
@@ -3756,50 +3849,99 @@
"maxMembersLabel": "حداکثر اعضا",
"member": "عضو",
"memberCount": "{count} عضو",
"membersLabel": "تا {count} عضو",
"messageSupport": "پیام به پشتیبانی",
"monthly": "ماهانه",
"monthlyBonusDescription": "پاداش ماهانه اعتبار",
"monthlyCredits": "اعتبار ماهانه",
"monthlyCreditsInfo": "این اعتبارها هر ماه شارژ می‌شوند و منتقل نمی‌شوند",
"monthlyCreditsLabel": "اعتبار ماهانه",
"monthlyCreditsPerMemberLabel": "اعتبار ماهانه / هر عضو",
"monthlyCreditsRollover": "این اعتبارها به ماه بعد منتقل می‌شوند",
"monthlyCreditsUsedUpDescription": "اکنون در حال استفاده از اعتبارهای اضافی هستید.",
"monthlyCreditsUsedUpTitle": "اعتبار ماهانه تمام شده است. شارژ مجدد در {date}",
"monthlyCreditsUsedUpTitleNoDate": "اعتبار ماهانه تمام شده است",
"monthlyUsageProgress": "{used} از {total} اعتبار ماهانه مصرف شده است",
"mostPopular": "محبوب‌ترین",
"needTeamWorkspace": "به فضای کاری تیمی نیاز دارید؟",
"nextBillingCycle": "چرخه صورتحساب بعدی",
"nextMonthInvoice": "صورتحساب ماه آینده",
"outOfCreditsDescription": "برای ادامه تولید، اعتبار بیشتری اضافه کنید.",
"outOfCreditsTitle": "اعتبار شما تمام شده است. شارژ مجدد در {date}",
"outOfCreditsTitleNoDate": "اعتبار شما تمام شده است",
"partnerNodesBalance": "اعتبار «Partner Nodes»",
"partnerNodesCredits": "قیمت‌گذاری Partner Nodes",
"partnerNodesDescription": "برای اجرای مدل‌های تجاری/اختصاصی",
"partnerNodesPricingTable": "جدول قیمت‌گذاری Partner Nodeها",
"perMonth": "/ ماه",
"personalHeader": "پلن‌های شخصی فقط برای استفاده فردی هستند. {action}",
"personalHeaderAction": "برای افزودن هم‌تیمی، به پلن تیمی ارتقا دهید.",
"personalWorkspace": "فضای کاری شخصی",
"planLoadError": "امکان بارگذاری جزئیات پلن شما وجود ندارد.",
"planLoadErrorRetry": "تلاش مجدد",
"planScope": {
"personal": "برای استفاده شخصی",
"team": "برای تیم‌ها"
},
"plansAndPricing": "طرح‌ها و قیمت‌ها",
"plansForWorkspace": "طرح‌ها برای {workspace}",
"prepaidCreditsInfo": "اعتبارهای پیش‌پرداخت تا یک سال پس از تاریخ خرید منقضی می‌شوند.",
"prepaidDescription": "اعتبارهای پیش‌پرداخت",
"preview": {
"addCreditCard": "افزودن کارت اعتباری",
"afterThat": "پس از آن",
"backToAllPlans": "بازگشت به همه طرح‌ها",
"billedEachMonth": "{amount} هر ماه صورتحساب می‌شود. در هر زمان قابل لغو است.",
"commitment": "تعهد",
"confirm": "تأیید",
"confirmChange": "تأیید تغییر",
"confirmChangeTitle": "بررسی تغییر زمان‌بندی‌شده شما",
"confirmPayment": "تأیید پرداخت",
"confirmPlanChange": "تأیید تغییر طرح",
"confirmUpgradeCta": "تأیید ارتقاء",
"confirmUpgradeTitle": "تأیید ارتقاء",
"creditFromCurrent": "اعتبار از {plan} فعلی",
"creditsRefillMonthlyTo": "اعتبارها هر ماه شارژ می‌شوند تا",
"creditsRefillTo": "اعتبارها به این مقدار شارژ می‌شوند",
"creditsYoullGetToday": "اعتبارهایی که امروز دریافت می‌کنید",
"currentMonthly": "طرح ماهانه",
"eachMonthCreditsRefill": "هر ماه اعتبار به این مقدار بازنشانی می‌شود",
"eachYearCreditsRefill": "هر سال اعتبارها شارژ می‌شوند تا",
"ends": "پایان در {date}",
"everyMonthStarting": "هر ماه از {date} شروع می‌شود",
"hideFeatures": "مخفی کردن امکانات",
"newMonthlySubscription": "اشتراک ماهانه جدید",
"nextPaymentDue": "پرداخت بعدی در {date}. هر زمان می‌توانید لغو کنید.",
"paymentPopupBlocked": "امکان باز کردن صفحه پرداخت وجود ندارد — لطفاً پاپ‌آپ‌ها را فعال کنید و دوباره تلاش کنید.",
"perMember": "/ هر عضو",
"privacyPolicy": "سیاست حفظ حریم خصوصی",
"proratedCharge": "هزینه متناسب برای {plan}",
"proratedRefund": "بازپرداخت متناسب برای {plan}",
"refillReplacesNote": "جایگزین شارژ ماهانه شما می‌شود. موجودی فعلی حفظ می‌شود.",
"showMoreFeatures": "نمایش امکانات بیشتر",
"starting": "شروع از {date}",
"startingToday": "شروع از امروز",
"startsOn": "شروع از {date}",
"stayOnUntil": "تا {date} در {plan} باقی خواهید ماند.",
"subscribeToPlan": "اشتراک پلن {plan}",
"switchToPlan": "تغییر به پلن {plan}",
"switchesToday": "تغییرات امروز",
"terms": "شرایط",
"termsAgreement": "با ادامه، شما با {terms} و {privacy} Comfy Org موافقت می‌کنید.",
"totalDueToday": "مبلغ قابل پرداخت امروز"
"totalDueToday": "مبلغ قابل پرداخت امروز",
"yearlySubscription": "اشتراک سالانه",
"youllBeCharged": "شما مبلغ زیر را پرداخت خواهید کرد"
},
"pricingBlurb": "*بر اساس این قالب، {seeDetails}. برای {questions} یا {enterpriseDiscussions} با ما تماس بگیرید. برای جزئیات بیشتر قیمت‌گذاری، {clickHere}.",
"pricingBlurbClickHere": "اینجا کلیک کنید",
"pricingBlurbEnterprise": "بحث‌های سازمانی",
"pricingBlurbQuestions": "سؤالات",
"pricingBlurbSeeDetails": "جزئیات را ببینید",
"reactivatePlan": "فعال‌سازی مجدد پلن",
"refillsDate": "شارژ مجدد در {date}",
"refillsNextCycle": "شارژ مجدد در چرخه بعدی",
"refreshCredits": "به‌روزرسانی اعتبارها",
"remaining": "باقی‌مانده",
"renewsDate": "تمدید در {date}",
"renewsOnDate": "تمدید در تاریخ {date}",
"required": {
"pollingFailed": "فعال‌سازی اشتراک ناموفق بود",
"pollingSuccess": "اشتراک با موفقیت فعال شد!",
@@ -3811,7 +3953,11 @@
"resubscribe": "تمدید اشتراک",
"resubscribeSuccess": "اشتراک با موفقیت فعال شد",
"resubscribeTo": "تمدید اشتراک {plan}",
"saveYearly": "٪۲۰ صرفه‌جویی",
"saveYearlyUpTo": "تا ۲۰٪ صرفه‌جویی کنید",
"soloUseOnly": "فقط برای استفاده فردی",
"subscribe": "اشتراک",
"subscribeFailed": "اشتراک‌گذاری ناموفق بود",
"subscribeForMore": "ارتقاء",
"subscribeNow": "هم‌اکنون اشتراک بگیرید",
"subscribeTo": "اشتراک در {plan}",
@@ -3819,10 +3965,46 @@
"subscribeToRun": "اشتراک",
"subscribeToRunFull": "اشتراک برای اجرا",
"subscriptionRequiredMessage": "برای اجرای workflowها در Cloud، اشتراک لازم است.",
"success": {
"allSet": "همه چیز آماده است",
"inviteEmailsPlaceholder": "ایمیل‌ها را با ویرگول جدا کنید",
"inviteSubtext": "می‌توانید بعداً از بخش تنظیمات نیز افراد را دعوت کنید",
"inviteTitle": "تیم خود را دعوت کنید",
"planUpdated": "پلن شما با موفقیت به‌روزرسانی شد.",
"receiptEmailed": "رسید به ایمیل شما ارسال شد.",
"sendInvites": "ارسال دعوت‌نامه"
},
"teamHeader": "برای تیم‌هایی که قصد همکاری دارند. به اعضای بیشتری نیاز دارید؟ {learnMore} درباره پلن سازمانی.",
"teamHeaderLearnMore": "بیشتر بدانید",
"teamPerks": {
"concurrentRuns": "اعضا می‌توانند workflowها را به صورت همزمان اجرا کنند",
"inviteMembers": "دعوت اعضا",
"rolePermissions": "دسترسی مبتنی بر نقش",
"sharedCreditPool": "استخر اعتبار مشترک برای همه اعضا"
},
"teamPlan": {
"changePlan": "تغییر پلن",
"comingSoonLabel": "به‌زودی:",
"cta": "اشتراک سالانه تیمی",
"ctaMonthly": "اشتراک ماهانه تیمی",
"currentPlan": "پلن فعلی",
"detailsTitle": "جزئیات",
"name": "پلن تیمی",
"perkConcurrentRuns": "اعضا می‌توانند workflowها را به‌صورت همزمان اجرا کنند",
"perkInviteMembers": "دعوت اعضای تیم",
"perkProjectAssets": "مدیریت پروژه و دارایی‌ها",
"perkRolePermissions": "دسترسی مبتنی بر نقش",
"perkSharedPool": "استخر اعتبار مشترک برای همه اعضا",
"tagline": "اشتراک اعتباری ماهانه دلخواه خود را انتخاب کنید. با اعتبار بیشتر، تخفیف بیشتری دریافت کنید.",
"unavailable": "این طرح تیمی در حال حاضر در دسترس نیست."
},
"teamPlanIncludes": "پلن شما شامل همه امکانات {plan} و همچنین:",
"teamPlanName": "تیمی",
"teamWorkspace": "فضای کاری تیمی",
"tierNameYearly": "{name} سالانه",
"tiers": {
"creator": {
"feature1": "امکان وارد کردن مدل‌های شخصی",
"name": "خالق"
},
"founder": {
@@ -3832,9 +4014,12 @@
"name": "رایگان"
},
"pro": {
"feature1": "زمان اجرای workflow طولانی‌تر (تا ۱ ساعت)",
"name": "حرفه‌ای"
},
"standard": {
"feature1": "حداکثر زمان اجرای workflow: ۳۰ دقیقه",
"feature2": "امکان افزودن اعتبار بیشتر در هر زمان",
"name": "استاندارد"
}
},
@@ -3847,6 +4032,8 @@
"upgradeToAddCredits": "برای افزودن اعتبار ارتقاء دهید",
"usdPerMonth": "دلار آمریکا / ماه",
"usdPerMonthPerMember": "دلار آمریکا / ماه / هر عضو",
"usedAfterMonthly": "پس از اتمام اعتبار ماهانه مصرف می‌شود",
"videoEstimate": "تولید حدود ~{count} ویدیو ۵ ثانیه‌ای*",
"videoEstimateExplanation": "این تخمین‌ها بر اساس قالب Wan 2.2 Image-to-Video با تنظیمات پیش‌فرض (۵ ثانیه، ۶۴۰×۶۴۰، ۱۶ فریم بر ثانیه، ۴ مرحله نمونه‌گیری) است.",
"videoEstimateHelp": "جزئیات بیشتر درباره این قالب",
"videoEstimateLabel": "تخمین تعداد ویدیوهای ۵ ثانیه‌ای تولید شده با قالب Wan 2.2 Image-to-Video",
@@ -3856,10 +4043,10 @@
"viewMoreDetails": "مشاهده جزئیات بیشتر",
"viewMoreDetailsPlans": "مشاهده جزئیات بیشتر درباره طرح‌ها و قیمت‌ها",
"viewUsageHistory": "مشاهده تاریخچه استفاده",
"whatsIncluded": "شامل موارد زیر:",
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
"yearly": "سالانه",
"yearlyCreditsLabel": "کل اعتبار سالانه",
"yearlyDiscount": "٪۲۰ تخفیف",
"yourPlanIncludes": "طرح شما شامل:"
},
"tabMenu": {
@@ -4150,6 +4337,19 @@
}
},
"workspacePanel": {
"changeRoleDialog": {
"demoteConfirm": "تنزل به عضو",
"demoteMessage": "دسترسی مدیریتی را از دست خواهد داد.",
"demoteTitle": "آیا {name} را به عضو عادی تنزل دهید؟",
"error": "به‌روزرسانی نقش ناموفق بود",
"promoteConfirm": "تبدیل به مالک",
"promoteIntro": "او قادر خواهد بود:",
"promotePermissionCredits": "افزودن اعتبار اضافی",
"promotePermissionManage": "مدیریت اعضا، روش‌های پرداخت و تنظیمات workspace",
"promotePermissionRoles": "ارتقاء یا تنزل سایر مالکان (به جز سازنده workspace).",
"promoteTitle": "آیا {name} را مالک کنید؟",
"success": "نقش با موفقیت به‌روزرسانی شد"
},
"createWorkspaceDialog": {
"create": "ایجاد",
"message": "محیط‌های کاری به اعضا اجازه می‌دهند از یک اعتبار مشترک استفاده کنند. پس از ایجاد، شما مالک خواهید بود.",
@@ -4174,17 +4374,11 @@
"inviteLimitReached": "شما به حداکثر تعداد ۵۰ عضو رسیده‌اید",
"inviteMember": "دعوت عضو",
"inviteMemberDialog": {
"createLink": "ایجاد لینک",
"linkCopied": "کپی شد",
"linkCopyFailed": "کپی لینک ناموفق بود",
"linkStep": {
"copyLink": "کپی لینک",
"done": "انجام شد",
"message": "اطمینان حاصل کنید که حساب کاربری او از این ایمیل استفاده می‌کند.",
"title": "این لینک را برای شخص ارسال کنید"
},
"message": "یک لینک دعوت قابل اشتراک‌گذاری برای ارسال به شخص ایجاد کنید",
"failedCount": "ارسال {count} دعوت‌نامه ناموفق بود. دوباره تلاش کنید. | ارسال {count} دعوت‌نامه ناموفق بود. دوباره تلاش کنید.",
"invalidEmailCount": "{count} آدرس ایمیل نامعتبر | {count} آدرس ایمیل نامعتبر",
"invitedMessage": "دعوت‌نامه به {emails} ارسال شد | دعوت‌نامه‌ها به {emails} ارسال شدند",
"placeholder": "ایمیل شخص را وارد کنید",
"seatLimitReached": "شما می‌توانید تا {count} همکار دعوت کنید. | شما می‌توانید تا {count} همکار دعوت کنید.",
"title": "دعوت یک نفر به این فضای کاری"
},
"inviteUpsellDialog": {
@@ -4192,8 +4386,7 @@
"messageSingleSeat": "پلن Standard فقط یک صندلی برای مالک workspace دارد. برای دعوت اعضای بیشتر، به پلن Creator یا بالاتر ارتقا دهید تا امکان افزودن چندین نفر فعال شود.",
"titleNotSubscribed": "برای دعوت اعضا نیاز به اشتراک دارید",
"titleSingleSeat": "پلن فعلی شما فقط یک نفر را پشتیبانی می‌کند",
"upgradeToCreator": "ارتقا به Creator",
"viewPlans": "مشاهده پلن‌ها"
"upgradeToTeam": "ارتقاء به تیم"
},
"leaveDialog": {
"leave": "خروج",
@@ -4202,30 +4395,35 @@
},
"members": {
"actions": {
"copyLink": "کپی لینک دعوت",
"cancelInvite": "لغو دعوت‌نامه",
"changeRole": "تغییر نقش",
"removeMember": "حذف عضو",
"revokeInvite": "لغو دعوت"
"resendInvite": "ارسال مجدد دعوت‌نامه"
},
"columns": {
"expiryDate": "تاریخ انقضا",
"inviteDate": "تاریخ دعوت",
"joinDate": "تاریخ پیوستن"
"role": قش"
},
"createNewWorkspace": "یک فضای کاری جدید ایجاد کنید.",
"contactUs": "با ما تماس بگیرید",
"header": "اعضا",
"membersCount": "{count}/۵۰ عضو",
"needMoreMembers": "به اعضای بیشتری نیاز دارید؟",
"noInvites": "هیچ دعوت‌نامه‌ای در انتظار نیست",
"noMembers": "هیچ عضوی وجود ندارد",
"pendingInvitesCount": "{count} دعوت‌نامه در انتظار | {count} دعوت‌نامه در انتظار",
"personalWorkspaceMessage": "در حال حاضر نمی‌توانید اعضای دیگری به فضای کاری شخصی خود دعوت کنید. برای افزودن اعضا به یک فضای کاری،",
"reactivateTeam": "فعال‌سازی مجدد تیم",
"searchPlaceholder": "جستجو...",
"tabs": {
"active": "فعال",
"pendingCount": "در انتظار ({count})"
},
"upsellBannerSubscribe": "برای دعوت اعضای تیم به این workspace، به پلن Creator یا بالاتر اشتراک تهیه کنید.",
"upsellBannerUpgrade": "برای دعوت اعضای بیشتر به این workspace، به پلن Creator یا بالاتر ارتقا دهید.",
"viewPlans": "مشاهده پلن‌ها"
"upgradeToTeam": "ارتقاء به تیم",
"upsellBanner": "برای افزودن همکاران، پلن خود را ارتقاء دهید.",
"upsellBannerReactivate": "برای افزودن همکاران بیشتر، پلن خود را فعال‌سازی مجدد کنید."
},
"menu": {
"creatorCannotLeave": "سازنده workspace نمی‌تواند workspace ایجادشده را ترک کند.",
"deleteWorkspace": "حذف محیط کاری",
"deleteWorkspaceDisabledTooltip": "ابتدا اشتراک فعال محیط کاری خود را لغو کنید",
"editWorkspace": "ویرایش جزئیات محیط کاری",
@@ -4254,6 +4452,8 @@
"failedToFetchWorkspaces": "بارگذاری فضاهای کاری ناموفق بود",
"failedToLeaveWorkspace": "خروج از محیط کاری ناموفق بود",
"failedToUpdateWorkspace": "به‌روزرسانی محیط کاری ناموفق بود",
"inviteResendFailed": "ارسال مجدد دعوت‌نامه ناموفق بود",
"inviteResent": "دعوت‌نامه مجدداً ارسال شد",
"workspaceCreated": {
"message": "برای یک طرح اشتراک ثبت‌نام کنید، هم‌تیمی‌ها را دعوت کنید و همکاری را آغاز نمایید.",
"subscribe": "اشتراک",

View File

@@ -730,6 +730,49 @@
}
}
},
"BuildJsonPromptIdeogram": {
"description": "ساخت یک پرامپت JSON برای مدل Ideogram 4.",
"display_name": "ساخت پرامپت JSON (Ideogram)",
"inputs": {
"aesthetics": {
"name": "aesthetics",
"tooltip": "کلمات کلیدی زیبایی‌شناسی اجباری (مثلاً: moody، cinematic، desaturated)."
},
"background": {
"name": "background",
"tooltip": "توضیح اجباری درباره پس‌زمینه یا محیط تصویر."
},
"color_palette": {
"name": "color_palette",
"tooltip": "کدهای رنگ Hex که رنگ‌های غالب تصویر را تعیین می‌کنند. تا ۱۶ ورودی."
},
"element": {
"name": "element",
"tooltip": "عناصر پرامپت از node ایجاد جعبه‌های مرزی."
},
"high_level_description": {
"name": "high_level_description",
"tooltip": "توضیح اختیاری درباره تصویر در یک یا دو جمله. به شدت توصیه می‌شود."
},
"lighting": {
"name": "lighting",
"tooltip": "توضیح اجباری نورپردازی (مثلاً: golden hour، rim light، dramatic shadows)."
},
"medium": {
"name": "medium",
"tooltip": "نوع مدیوم اجباری (مثلاً: photograph، illustration، 3d_render، painting، graphic_design). زمانی که style = photo باشد، مقدار را photograph قرار دهید."
},
"style": {
"name": "style"
}
},
"outputs": {
"0": {
"name": "prompt",
"tooltip": null
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "تولید ویدیو با استفاده از Seedance 2.0 از تصویر اولین فریم و در صورت نیاز تصویر آخرین فریم.",
"display_name": "ByteDance Seedance 2.0 تبدیل اولین-آخرین فریم به ویدیو",
@@ -2633,6 +2676,40 @@
}
}
},
"ConvertArrayToString": {
"display_name": "تبدیل آرایه به رشته",
"inputs": {
"array": {
"name": "array"
},
"indent": {
"name": "indent",
"tooltip": "تعداد فاصله برای هر سطح تورفتگی. مقدار ۰ یک رشته تک‌خطی فشرده تولید می‌کند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ConvertDictionaryToString": {
"display_name": "تبدیل دیکشنری به رشته",
"inputs": {
"dictionary": {
"name": "dictionary"
},
"indent": {
"name": "indent",
"tooltip": "تعداد فاصله برای هر سطح تورفتگی. مقدار ۰ یک رشته تک‌خطی فشرده تولید می‌کند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"CosmosImageToVideoLatent": {
"display_name": "CosmosImageToVideoLatent",
"inputs": {
@@ -2695,6 +2772,42 @@
}
}
},
"CreateBoundingBoxes": {
"description": "کشیدن جعبه‌های مرزی در یک بوم. خروجی شامل عناصر پرامپت Ideogram، جعبه‌های مرزی در فضای پیکسلی و یک تصویر پیش‌نمایش است.",
"display_name": "ایجاد جعبه‌های مرزی",
"inputs": {
"background": {
"name": "background",
"tooltip": "تصویر اختیاری که به عنوان پس‌زمینه در بوم و پیش‌نمایش استفاده می‌شود."
},
"editor_state": {
"name": "editor_state",
"tooltip": "جعبه‌های مرزی را رسم کنید و نوع هر جعبه، متن، توضیح و پالت رنگ را تعیین کنید. با عنصر پس‌زمینه شروع و با پیش‌زمینه پایان دهید."
},
"height": {
"name": "height",
"tooltip": "ارتفاع بوم و شبکه پیکسلی برای جعبه‌های مرزی."
},
"width": {
"name": "width",
"tooltip": "عرض بوم و شبکه پیکسلی برای جعبه‌های مرزی."
}
},
"outputs": {
"0": {
"name": "preview",
"tooltip": null
},
"1": {
"name": "bboxes",
"tooltip": null
},
"2": {
"name": "elements",
"tooltip": null
}
}
},
"CreateCameraInfo": {
"description": "ساخت camera_info. حالت 'orbit' با استفاده از yaw/pitch/distance به دور هدف می‌چرخد؛ حالت 'look_at' دوربین را در موقعیت جهانی قرار می‌دهد. مختصات در فضای جهانی بیننده (راست‌دست، محور Y رو به بالا) هستند.",
"display_name": "ایجاد اطلاعات دوربین",
@@ -12025,6 +12138,131 @@
}
}
},
"ModelMergeKrea2": {
"display_name": "ModelMergeKrea2",
"inputs": {
"blocks_0_": {
"name": "blocks.0."
},
"blocks_10_": {
"name": "blocks.10."
},
"blocks_11_": {
"name": "blocks.11."
},
"blocks_12_": {
"name": "blocks.12."
},
"blocks_13_": {
"name": "blocks.13."
},
"blocks_14_": {
"name": "blocks.14."
},
"blocks_15_": {
"name": "blocks.15."
},
"blocks_16_": {
"name": "blocks.16."
},
"blocks_17_": {
"name": "blocks.17."
},
"blocks_18_": {
"name": "blocks.18."
},
"blocks_19_": {
"name": "blocks.19."
},
"blocks_1_": {
"name": "blocks.1."
},
"blocks_20_": {
"name": "blocks.20."
},
"blocks_21_": {
"name": "blocks.21."
},
"blocks_22_": {
"name": "blocks.22."
},
"blocks_23_": {
"name": "blocks.23."
},
"blocks_24_": {
"name": "blocks.24."
},
"blocks_25_": {
"name": "blocks.25."
},
"blocks_26_": {
"name": "blocks.26."
},
"blocks_27_": {
"name": "blocks.27."
},
"blocks_2_": {
"name": "blocks.2."
},
"blocks_3_": {
"name": "blocks.3."
},
"blocks_4_": {
"name": "blocks.4."
},
"blocks_5_": {
"name": "blocks.5."
},
"blocks_6_": {
"name": "blocks.6."
},
"blocks_7_": {
"name": "blocks.7."
},
"blocks_8_": {
"name": "blocks.8."
},
"blocks_9_": {
"name": "blocks.9."
},
"first_": {
"name": "first."
},
"last_": {
"name": "last."
},
"model1": {
"name": "model1"
},
"model2": {
"name": "model2"
},
"tmlp_": {
"name": "tmlp."
},
"tproj_": {
"name": "tproj."
},
"txtfusion_layerwise_blocks_0_": {
"name": "txtfusion.layerwise_blocks.0."
},
"txtfusion_layerwise_blocks_1_": {
"name": "txtfusion.layerwise_blocks.1."
},
"txtfusion_projector_": {
"name": "txtfusion.projector."
},
"txtfusion_refiner_blocks_0_": {
"name": "txtfusion.refiner_blocks.0."
},
"txtfusion_refiner_blocks_1_": {
"name": "txtfusion.refiner_blocks.1."
},
"txtmlp_": {
"name": "txtmlp."
}
}
},
"ModelMergeLTXV": {
"display_name": "ModelMergeLTXV",
"inputs": {
@@ -17698,6 +17936,23 @@
}
}
},
"SeedNode": {
"display_name": "بذر",
"inputs": {
"fixed": {
"name": "کنترل پس از تولید"
},
"seed": {
"name": "بذر"
}
},
"outputs": {
"0": {
"name": "بذر",
"tooltip": null
}
}
},
"SelectCLIPDevice": {
"description": "رمزگذار متنی CLIP را روی یک دستگاه خاص قرار دهید (پیش‌فرض / cpu / gpu:N).\n\n- «default» دستگاه اختصاص داده‌شده توسط loader را بازمی‌گرداند.\n- «cpu» هر دو دستگاه بارگذاری و offload را به CPU اختصاص می‌دهد.\n- «gpu:N» دستگاه بارگذاری را به Nامین GPU موجود اختصاص می‌دهد.\n\nزمانی که دستگاه انتخاب‌شده روی سیستم فعلی وجود نداشته باشد\n(مثلاً یک workflow ساخته‌شده روی سیستمی با ۲ GPU که روی سیستمی با ۱ GPU باز می‌شود)،\nnode بدون تغییر CLIP را عبور می‌دهد و به جای خطا، پیامی را ثبت می‌کند.",
"display_name": "انتخاب دستگاه CLIP",

View File

@@ -92,6 +92,7 @@
"errorUserTokenAccessDenied": "Votre jeton API na pas accès à cette ressource. Veuillez vérifier les autorisations de votre jeton.",
"errorUserTokenInvalid": "Votre jeton API enregistré est invalide ou expiré. Veuillez le mettre à jour dans les paramètres.",
"failedToCreateNode": "Échec de la création du nœud. Veuillez réessayer ou consulter la console pour plus de détails.",
"failedToSetModelValue": "Nœud ajouté, mais son modèle na pas pu être défini automatiquement. Consultez la console pour plus de détails.",
"fileFormats": "Formats de fichier",
"fileName": "Nom du fichier",
"fileSize": "Taille du fichier",
@@ -241,7 +242,8 @@
"auth/user-not-found": "Aucun compte trouvé avec cette adresse e-mail. Souhaitez-vous créer un nouveau compte ?",
"auth/weak-password": "Le mot de passe est trop faible. Veuillez utiliser un mot de passe plus fort avec au moins 6 caractères.",
"auth/wrong-password": "Le mot de passe que vous avez saisi est incorrect. Veuillez réessayer.",
"generic": "Une erreur s'est produite lors de votre connexion. Veuillez réessayer."
"generic": "Une erreur s'est produite lors de votre connexion. Veuillez réessayer.",
"signupBlocked": "Nous ne pouvons pas créer votre compte pour le moment. Veuillez réessayer plus tard. Si le problème persiste, contactez support@comfy.org."
},
"login": {
"andText": "et",
@@ -323,6 +325,11 @@
"signUpWithGithub": "S'inscrire avec Github",
"signUpWithGoogle": "S'inscrire avec Google",
"title": "Créer un compte"
},
"turnstile": {
"expired": "La vérification a expiré. Veuillez recommencer le défi.",
"failed": "Échec de la vérification. Veuillez réessayer.",
"submitBlockedHint": "Veuillez compléter le défi de vérification ci-dessus pour activer l'inscription."
}
},
"batch": {
@@ -346,6 +353,17 @@
"x": "X",
"y": "Y"
},
"boundingBoxes": {
"clearAll": "Tout effacer",
"clickRegionToEdit": "Cliquez sur une région pour la modifier.",
"colors": "palette_de_couleurs",
"descLabel": "description",
"descPlaceholder": "description de cette région",
"textLabel": "Texte",
"textPlaceholder": "texte à afficher (tel quel)",
"typeObj": "objet",
"typeText": "texte"
},
"breadcrumbsMenu": {
"app": "Application",
"blueprint": "Plan",
@@ -755,6 +773,13 @@
"creditsAvailable": "Crédits disponibles",
"details": "Détails",
"eventType": "Type d'événement",
"eventTypes": {
"accountCreated": "Compte créé",
"apiNodeUsage": "Utilisation du nœud partenaire",
"apiUsage": "Utilisation de l'API",
"creditAdded": "Crédits ajoutés",
"gpuUsage": "Utilisation du GPU"
},
"faqs": "FAQ",
"invoiceHistory": "Historique des factures",
"lastUpdated": "Dernière mise à jour",
@@ -811,6 +836,7 @@
},
"dataTypes": {
"*": "*",
"ARRAY": "TABLEAU",
"AUDIO": "AUDIO",
"AUDIO_ENCODER": "ENCODEUR_AUDIO",
"AUDIO_ENCODER_OUTPUT": "SORTIE_ENCODEUR_AUDIO",
@@ -818,11 +844,13 @@
"BACKGROUND_REMOVAL": "SUPPRESSION_ARRIÈRE-PLAN",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOÎTE ENGLOBANTE",
"BOUNDING_BOXES": "BOÎTES DE DÉLIMITATION",
"CAMERA_CONTROL": "Contrôle de la caméra",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "SORTIE_CLIP_VISION",
"COLOR": "COULEUR",
"COLORS": "COULEURS",
"COMBO": "COMBO",
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
@@ -832,6 +860,7 @@
"CURVE": "COURBE",
"DA3_GEOMETRY": "DA3_GEOMETRY",
"DA3_MODEL": "DA3_MODEL",
"DICT": "DICT",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "MODÈLE_DE_DÉTECTION_DE_VISAGE",
"FACE_LANDMARKS": "FACE_LANDMARKS",
@@ -1830,6 +1859,35 @@
"zoomOptions": "Options de zoom",
"zoomOut": "Zoom arrière"
},
"hdrViewer": {
"channel": "Canal",
"channels": {
"a": "Alpha",
"b": "B",
"g": "V",
"luminance": "Luminance",
"r": "R",
"rgb": "RVB"
},
"clipWarnings": "Avertissements de clipping",
"dither": "Tramage",
"exposure": "Exposition",
"failedToLoad": "Échec du chargement de l'image HDR",
"fitView": "Ajuster",
"hdrImage": "Image HDR",
"histogram": "Histogramme",
"inf": "Inf",
"max": "Max",
"mean": "Moyenne",
"min": "Min",
"nan": "NaN",
"normalizeExposure": "Exposition auto",
"openInHdrViewer": "Ouvrir dans la visionneuse HDR",
"resolution": "Résolution",
"sourceGamut": "Gamme source",
"stdDev": "Écart-type",
"title": "Visionneuse HDR"
},
"help": {
"helpCenterMenu": "Menu du centre daide",
"recentReleases": "Dernières versions"
@@ -2957,6 +3015,10 @@
"uploadError": "Échec du téléversement de l'image du peintre : {status} - {statusText}",
"width": "Largeur"
},
"palette": {
"addColor": "Ajouter une couleur",
"swatchTitle": "Cliquez pour éditer · glissez pour réordonner · clic droit pour supprimer"
},
"progressToast": {
"allDownloadsCompleted": "Tous les téléchargements sont terminés",
"downloadingModel": "Téléchargement du modèle...",
@@ -3041,7 +3103,6 @@
"color": "Couleur du nœud",
"editSubgraph": "Modifier le sous-graphe",
"editTitle": "Modifier le titre",
"enterSubgraph": "Entrer dans le sous-graphe",
"errorHelp": "Pour plus d'aide, {github} ou {support}",
"errorHelpGithub": "soumettre un ticket GitHub",
"errorHelpSupport": "contacter notre support",
@@ -3661,6 +3722,10 @@
"addApiCredits": "Ajouter des crédits API",
"addCredits": "Ajouter des crédits",
"addCreditsLabel": "Ajoutez des crédits à tout moment",
"additionalCredits": "Crédits supplémentaires",
"additionalCreditsInUse": "En cours d'utilisation",
"additionalCreditsInfo": "À propos des crédits supplémentaires",
"additionalCreditsTooltip": "Crédits ajoutés à votre forfait. Utilisés après l'épuisement des crédits mensuels. Chaque crédit expire un an après l'achat.",
"benefits": {
"benefit1": "Crédits mensuels pour les Nœuds Partenaires — rechargez si nécessaire",
"benefit1FreeTier": "Plus de crédits mensuels, recharge à tout moment",
@@ -3682,27 +3747,55 @@
"keepSubscription": "Conserver l'abonnement",
"title": "Annuler l'abonnement"
},
"cancelSubscription": "Annuler labonnement",
"cancelPlan": "Annuler labonnement",
"cancelSuccess": "Abonnement annulé avec succès",
"canceled": "Annulé",
"canceledCard": {
"description": "Vous ne serez plus facturé. Vos fonctionnalités restent actives jusqu'au {date}.",
"title": "Votre abonnement a été annulé"
},
"changePlan": "Changer dabonnement",
"changeTo": "Changer pour {plan}",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo Comfy Cloud",
"contactOwnerToSubscribe": "Contactez le propriétaire de lespace de travail pour vous abonner",
"contactUs": "Contactez-nous",
"creditSliderSave": "Économisez {percent}% ({amount})",
"creditsLeftOfTotal": "{remaining} restant(s) sur {total}",
"creditsRemainingThisMonth": "Crédits restants ce mois-ci",
"creditsRemainingThisYear": "Crédits restants cette année",
"creditsUsed": "{used} utilisé(s)",
"creditsYouveAdded": "Crédits ajoutés",
"currentPlan": "Forfait actuel",
"customLoRAsLabel": "Importer vos propres LoRAs",
"description": "Choisissez le forfait qui vous convient",
"descriptionWorkspace": "Choisissez la meilleure offre pour votre espace de travail",
"downgrade": {
"body": "Tous les autres membres de cet espace de travail seront immédiatement supprimés.",
"confirm": "Changer de plan",
"confirmationPhrase": "Je comprends",
"confirmationPrompt": "Tapez « {phrase} » pour confirmer.",
"failed": "Échec du changement de plan",
"failedAfterMemberRemoval": "Les membres de léquipe ont été retirés, mais le changement de plan na pas abouti — veuillez réessayer ou contacter le support",
"memberRemovalFailed": "Impossible de retirer {email} de léquipe — certains membres ont peut-être déjà été retirés et votre plan na pas été modifié",
"notAllowed": "Ce changement de plan n'est pas disponible",
"paymentMethodRequired": "Un moyen de paiement est requis pour changer de plan",
"paymentPageBlocked": "Impossible douvrir la page de paiement — veuillez réessayer",
"title": "Passer au plan {plan} ?"
},
"endsOnDate": "Se termine le {date}",
"enterprise": {
"cta": "En savoir plus",
"flexibility": "Vous cherchez plus de flexibilité ou des fonctionnalités personnalisées ?",
"name": "Entreprise",
"needMoreMembers": "Besoin de plus de membres ?",
"reachOut": "Contactez-nous pour planifier un échange."
},
"everythingInPlus": "Tout ce qui est dans {plan}, plus :",
"expiresDate": "Expire le {date}",
"freePerks": {
"maxRuntime": "{duration} de temps dexécution maximum"
},
"freeTier": {
"description": "Votre plan gratuit inclut {credits} crédits chaque mois pour essayer Comfy Cloud.",
"descriptionGeneric": "Votre plan gratuit inclut une allocation mensuelle de crédits pour essayer Comfy Cloud.",
@@ -3730,7 +3823,7 @@
"inviteUpTo": "Invitez jusquà",
"invoiceHistory": "Historique des factures",
"learnMore": "En savoir plus",
"managePayment": "Gérer le paiement",
"manageBilling": "Gérer la facturation",
"managePlan": "Gérer le forfait",
"manageSubscription": "Gérer l'abonnement",
"maxDuration": {
@@ -3744,50 +3837,99 @@
"maxMembersLabel": "Nombre max. de membres",
"member": "membre",
"memberCount": "{count} membre | {count} membres",
"membersLabel": "Jusqu'à {count} membres",
"messageSupport": "Contacter le support",
"monthly": "Mensuel",
"monthlyBonusDescription": "Bonus de crédits mensuel",
"monthlyCredits": "crédits mensuels",
"monthlyCreditsInfo": "Ces crédits se renouvellent chaque mois et ne sont pas reportés",
"monthlyCreditsLabel": "Crédits mensuels",
"monthlyCreditsPerMemberLabel": "Crédits mensuels / membre",
"monthlyCreditsRollover": "Ces crédits seront reportés au mois suivant",
"monthlyCreditsUsedUpDescription": "Vous utilisez maintenant des crédits supplémentaires.",
"monthlyCreditsUsedUpTitle": "Crédits mensuels épuisés. Recharge le {date}",
"monthlyCreditsUsedUpTitleNoDate": "Crédits mensuels épuisés",
"monthlyUsageProgress": "{used} sur {total} crédits mensuels utilisés",
"mostPopular": "Le plus populaire",
"needTeamWorkspace": "Besoin dun espace de travail déquipe ?",
"nextBillingCycle": "prochain cycle de facturation",
"nextMonthInvoice": "Facture du mois prochain",
"outOfCreditsDescription": "Ajoutez des crédits pour continuer à générer.",
"outOfCreditsTitle": "Vous n'avez plus de crédits. Recharge le {date}",
"outOfCreditsTitleNoDate": "Vous n'avez plus de crédits",
"partnerNodesBalance": "Solde de crédits \"Nœuds Partenaires\"",
"partnerNodesCredits": "Crédits Nœuds Partenaires",
"partnerNodesDescription": "Pour exécuter des modèles commerciaux/propriétaires",
"partnerNodesPricingTable": "Tableau des tarifs des Partner Nodes",
"perMonth": "USD / mois",
"personalHeader": "Les plans personnels sont réservés à un usage individuel. {action}",
"personalHeaderAction": "Pour ajouter des coéquipiers, abonnez-vous au plan équipe.",
"personalWorkspace": "Espace de travail personnel",
"planLoadError": "Nous n'avons pas pu charger les détails de votre abonnement.",
"planLoadErrorRetry": "Réessayer",
"planScope": {
"personal": "Pour usage personnel",
"team": "Pour les équipes"
},
"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": {
"addCreditCard": "Ajouter une carte bancaire",
"afterThat": "Après cela",
"backToAllPlans": "Retour à toutes les offres",
"billedEachMonth": "{amount} facturé chaque mois. Annulez à tout moment.",
"commitment": "engagement",
"confirm": "Confirmer",
"confirmChange": "Confirmer la modification",
"confirmChangeTitle": "Vérifiez votre modification programmée",
"confirmPayment": "Confirmer votre paiement",
"confirmPlanChange": "Confirmer le changement d'offre",
"confirmUpgradeCta": "Confirmer la mise à niveau",
"confirmUpgradeTitle": "Confirmer votre mise à niveau",
"creditFromCurrent": "Crédit du forfait actuel {plan}",
"creditsRefillMonthlyTo": "Les crédits sont rechargés mensuellement à",
"creditsRefillTo": "Les crédits sont réinitialisés à",
"creditsYoullGetToday": "Crédits que vous recevrez aujourd'hui",
"currentMonthly": "forfait mensuel",
"eachMonthCreditsRefill": "Chaque mois, les crédits sont réinitialisés à",
"eachYearCreditsRefill": "Chaque année, les crédits sont rechargés à",
"ends": "Se termine le {date}",
"everyMonthStarting": "Chaque mois à partir du {date}",
"hideFeatures": "Masquer les fonctionnalités",
"newMonthlySubscription": "Nouvel abonnement mensuel",
"nextPaymentDue": "Prochain paiement dû le {date}. Annulez à tout moment.",
"paymentPopupBlocked": "Impossible d'ouvrir la page de paiement — veuillez autoriser les pop-ups et réessayer.",
"perMember": "/ membre",
"privacyPolicy": "Politique de confidentialité",
"proratedCharge": "Facturation au prorata pour {plan}",
"proratedRefund": "Remboursement au prorata pour {plan}",
"refillReplacesNote": "Remplace votre recharge mensuelle. Le solde existant est conservé.",
"showMoreFeatures": "Afficher plus de fonctionnalités",
"starting": "À partir du {date}",
"startingToday": "À partir d'aujourd'hui",
"startsOn": "Commence le {date}",
"stayOnUntil": "Vous resterez sur {plan} jusqu'au {date}.",
"subscribeToPlan": "Sabonner au plan {plan}",
"switchToPlan": "Passer au plan {plan}",
"switchesToday": "Changements aujourd'hui",
"terms": "Conditions",
"termsAgreement": "En continuant, vous acceptez les {terms} et la {privacy} de Comfy Org.",
"totalDueToday": "Total dû aujourd'hui"
"totalDueToday": "Total dû aujourd'hui",
"yearlySubscription": "Abonnement annuel",
"youllBeCharged": "Vous serez facturé"
},
"pricingBlurb": "*Basé sur ce modèle, {seeDetails}. Contactez-nous pour {questions} ou {enterpriseDiscussions}. Pour plus de détails sur les tarifs, {clickHere}.",
"pricingBlurbClickHere": "cliquez ici",
"pricingBlurbEnterprise": "discussions entreprise",
"pricingBlurbQuestions": "questions",
"pricingBlurbSeeDetails": "voir les détails",
"reactivatePlan": "Réactiver labonnement",
"refillsDate": "Recharge le {date}",
"refillsNextCycle": "Recharge au prochain cycle",
"refreshCredits": "Actualiser les crédits",
"remaining": "restant",
"renewsDate": "Renouvellement le {date}",
"renewsOnDate": "Renouvellement le {date}",
"required": {
"pollingFailed": "Échec de l'activation de l'abonnement",
"pollingSuccess": "Abonnement activé avec succès !",
@@ -3799,7 +3941,11 @@
"resubscribe": "Se réabonner",
"resubscribeSuccess": "Abonnement réactivé avec succès",
"resubscribeTo": "Se réabonner à {plan}",
"saveYearly": "Économisez 20 %",
"saveYearlyUpTo": "Économisez jusquà 20 %",
"soloUseOnly": "Usage solo uniquement",
"subscribe": "Sabonner",
"subscribeFailed": "Échec de l'abonnement",
"subscribeForMore": "Mettre à niveau",
"subscribeNow": "S'abonner maintenant",
"subscribeTo": "S'abonner à {plan}",
@@ -3807,10 +3953,46 @@
"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",
"success": {
"allSet": "Tout est prêt",
"inviteEmailsPlaceholder": "Entrez les emails séparés par des virgules",
"inviteSubtext": "Vous pouvez aussi inviter des personnes plus tard dans les paramètres",
"inviteTitle": "Invitez votre équipe",
"planUpdated": "Votre plan a été mis à jour avec succès.",
"receiptEmailed": "Un reçu vous a été envoyé par e-mail.",
"sendInvites": "Envoyer les invitations"
},
"teamHeader": "Pour les équipes souhaitant collaborer. Besoin de plus de membres ? {learnMore} sur loffre entreprise.",
"teamHeaderLearnMore": "En savoir plus",
"teamPerks": {
"concurrentRuns": "Les membres peuvent exécuter des workflows simultanément",
"inviteMembers": "Inviter des membres",
"rolePermissions": "Permissions basées sur les rôles",
"sharedCreditPool": "Crédit partagé pour tous les membres"
},
"teamPlan": {
"changePlan": "Changer de plan",
"comingSoonLabel": "Bientôt disponible :",
"cta": "Sabonner à loffre Équipe annuelle",
"ctaMonthly": "Sabonner à loffre Équipe mensuelle",
"currentPlan": "Plan actuel",
"detailsTitle": "Détails",
"name": "Plan Équipe",
"perkConcurrentRuns": "Les membres peuvent exécuter des workflows simultanément",
"perkInviteMembers": "Invitez des membres dans léquipe",
"perkProjectAssets": "Gestion des projets et des ressources",
"perkRolePermissions": "Permissions basées sur les rôles",
"perkSharedPool": "Crédits partagés pour tous les membres",
"tagline": "Choisissez votre propre abonnement mensuel de crédits. Bénéficiez dune plus grande remise avec un abonnement de crédits plus important.",
"unavailable": "Ce forfait d'équipe n'est pas disponible pour le moment."
},
"teamPlanIncludes": "Votre abonnement inclut tout dans {plan}, plus :",
"teamPlanName": "Équipe",
"teamWorkspace": "Espace de travail déquipe",
"tierNameYearly": "{name} Annuel",
"tiers": {
"creator": {
"feature1": "Importez vos propres modèles",
"name": "Créateur"
},
"founder": {
@@ -3820,9 +4002,12 @@
"name": "Gratuit"
},
"pro": {
"feature1": "Durée dexécution du workflow prolongée (jusquà 1 h)",
"name": "Pro"
},
"standard": {
"feature1": "Durée maximale dexécution du workflow : 30 minutes",
"feature2": "Ajoutez des crédits à tout moment",
"name": "Standard"
}
},
@@ -3835,6 +4020,8 @@
"upgradeToAddCredits": "Mettre à niveau pour ajouter des crédits",
"usdPerMonth": "USD / mois",
"usdPerMonthPerMember": "USD / mois / membre",
"usedAfterMonthly": "Utilisé après l'épuisement du forfait mensuel",
"videoEstimate": "Génère environ {count} vidéos de 5 s*",
"videoEstimateExplanation": "Ces estimations sont basées sur le modèle Wan 2.2 Image-to-Video avec les paramètres par défaut (5 secondes, 640x640, 16fps, échantillonnage en 4 étapes).",
"videoEstimateHelp": "Plus de détails sur ce modèle",
"videoEstimateLabel": "Nombre approx. de vidéos de 5s générées avec le modèle Wan 2.2 Image-to-Video",
@@ -3844,10 +4031,10 @@
"viewMoreDetails": "Voir plus de détails",
"viewMoreDetailsPlans": "Voir plus de détails sur les forfaits et tarifs",
"viewUsageHistory": "Voir l'historique d'utilisation",
"whatsIncluded": "Inclus :",
"workspaceNotSubscribed": "Cet espace de travail na pas dabonnement",
"yearly": "Annuel",
"yearlyCreditsLabel": "Crédits annuels totaux",
"yearlyDiscount": "20% DE RÉDUCTION",
"yourPlanIncludes": "Votre forfait comprend :"
},
"tabMenu": {
@@ -4138,6 +4325,19 @@
}
},
"workspacePanel": {
"changeRoleDialog": {
"demoteConfirm": "Rétrograder en membre",
"demoteMessage": "Il perdra l'accès administrateur.",
"demoteTitle": "Rétrograder {name} au statut de membre ?",
"error": "Échec de la mise à jour du rôle",
"promoteConfirm": "Nommer propriétaire",
"promoteIntro": "Il pourra :",
"promotePermissionCredits": "Ajouter des crédits supplémentaires",
"promotePermissionManage": "Gérer les membres, les moyens de paiement et les paramètres de l'espace de travail",
"promotePermissionRoles": "Promouvoir et rétrograder d'autres propriétaires (sauf le créateur de l'espace de travail).",
"promoteTitle": "Faire de {name} un propriétaire ?",
"success": "Rôle mis à jour"
},
"createWorkspaceDialog": {
"create": "Créer",
"message": "Les espaces de travail permettent aux membres de partager un même pool de crédits. Vous deviendrez le propriétaire après la création.",
@@ -4162,17 +4362,11 @@
"inviteLimitReached": "Vous avez atteint le maximum de 50 membres",
"inviteMember": "Inviter un membre",
"inviteMemberDialog": {
"createLink": "Créer le lien",
"linkCopied": "Copié",
"linkCopyFailed": "Échec de la copie du lien",
"linkStep": {
"copyLink": "Copier le lien",
"done": "Terminé",
"message": "Assurez-vous que son compte utilise cet email.",
"title": "Envoyez ce lien à la personne"
},
"message": "Créez un lien d'invitation partageable à envoyer à quelqu'un",
"failedCount": "Impossible d'envoyer {count} invitation. Réessayez. | Impossible d'envoyer {count} invitations. Réessayez.",
"invalidEmailCount": "{count} adresse e-mail invalide | {count} adresses e-mail invalides",
"invitedMessage": "Une invitation a été envoyée à {emails} | Des invitations ont été envoyées à {emails}",
"placeholder": "Entrez l'email de la personne",
"seatLimitReached": "Vous pouvez inviter jusqu'à {count} coéquipier. | Vous pouvez inviter jusqu'à {count} coéquipiers.",
"title": "Inviter une personne dans cet espace de travail"
},
"inviteUpsellDialog": {
@@ -4180,8 +4374,7 @@
"messageSingleSeat": "Le forfait Standard inclut une place pour le propriétaire de lespace de travail. Pour inviter dautres membres, passez au forfait Creator ou supérieur pour débloquer plusieurs places.",
"titleNotSubscribed": "Un abonnement est requis pour inviter des membres",
"titleSingleSeat": "Votre forfait actuel ne prend en charge quun seul utilisateur",
"upgradeToCreator": "Passer au forfait Creator",
"viewPlans": "Voir les forfaits"
"upgradeToTeam": "Passer à l'offre Équipe"
},
"leaveDialog": {
"leave": "Quitter",
@@ -4190,30 +4383,35 @@
},
"members": {
"actions": {
"copyLink": "Copier le lien d'invitation",
"cancelInvite": "Annuler l'invitation",
"changeRole": "Changer de rôle",
"removeMember": "Retirer le membre",
"revokeInvite": "Révoquer l'invitation"
"resendInvite": "Renvoyer l'invitation"
},
"columns": {
"expiryDate": "Date d'expiration",
"inviteDate": "Date d'invitation",
"joinDate": "Date d'adhésion"
"role": "Rôle"
},
"createNewWorkspace": "créez-en un nouveau.",
"contactUs": "Contactez-nous",
"header": "Membres",
"membersCount": "{count}/50 membres",
"needMoreMembers": "Besoin de plus de membres ?",
"noInvites": "Aucune invitation en attente",
"noMembers": "Aucun membre",
"pendingInvitesCount": "{count} invitation en attente | {count} invitations en attente",
"personalWorkspaceMessage": "Vous ne pouvez pas inviter d'autres membres dans votre espace de travail personnel pour le moment. Pour ajouter des membres à un espace de travail,",
"reactivateTeam": "Réactiver l'offre Équipe",
"searchPlaceholder": "Rechercher...",
"tabs": {
"active": "Actif",
"pendingCount": "En attente ({count})"
},
"upsellBannerSubscribe": "Abonnez-vous au forfait Creator ou supérieur pour inviter des membres à cet espace de travail.",
"upsellBannerUpgrade": "Passez au forfait Creator ou supérieur pour inviter des membres supplémentaires à cet espace de travail.",
"viewPlans": "Voir les forfaits"
"upgradeToTeam": "Passer à l'offre Équipe",
"upsellBanner": "Pour ajouter des coéquipiers, passez à une offre supérieure.",
"upsellBannerReactivate": "Pour ajouter plus de coéquipiers, réactivez votre offre."
},
"menu": {
"creatorCannotLeave": "Le créateur de l'espace de travail ne peut pas quitter l'espace qu'il a créé",
"deleteWorkspace": "Supprimer lespace de travail",
"deleteWorkspaceDisabledTooltip": "Annulez dabord labonnement actif de votre espace de travail",
"editWorkspace": "Modifier les détails de lespace de travail",
@@ -4242,6 +4440,8 @@
"failedToFetchWorkspaces": "Échec du chargement des espaces de travail",
"failedToLeaveWorkspace": "Échec de la sortie de lespace de travail",
"failedToUpdateWorkspace": "Échec de la mise à jour de lespace de travail",
"inviteResendFailed": "Échec de l'envoi de l'invitation",
"inviteResent": "Invitation renvoyée",
"workspaceCreated": {
"message": "Abonnez-vous à un plan, invitez des coéquipiers et commencez à collaborer.",
"subscribe": "S'abonner",

View File

@@ -730,6 +730,49 @@
}
}
},
"BuildJsonPromptIdeogram": {
"description": "Génère une invite JSON pour le modèle Ideogram 4.",
"display_name": "Générer une invite JSON (Ideogram)",
"inputs": {
"aesthetics": {
"name": "esthétique",
"tooltip": "Mots-clés esthétiques obligatoires (ex : sombre, cinématographique, désaturé)."
},
"background": {
"name": "arrière-plan",
"tooltip": "Description obligatoire de larrière-plan ou de lenvironnement de limage."
},
"color_palette": {
"name": "palette_de_couleurs",
"tooltip": "Codes couleur hexadécimaux pour orienter les couleurs dominantes de limage. Jusquà 16 entrées."
},
"element": {
"name": "élément",
"tooltip": "Éléments d'invite provenant du nœud Créer des boîtes englobantes."
},
"high_level_description": {
"name": "description_de_haut_niveau",
"tooltip": "Description optionnelle de limage en une ou deux phrases. Fortement recommandé."
},
"lighting": {
"name": "éclairage",
"tooltip": "Description obligatoire de léclairage (ex : heure dorée, contre-jour, ombres dramatiques)."
},
"medium": {
"name": "support",
"tooltip": "Type de support obligatoire (ex : photographie, illustration, rendu_3d, peinture, conception_graphique). Lorsque style = photo, choisir photographie."
},
"style": {
"name": "style"
}
},
"outputs": {
"0": {
"name": "invite",
"tooltip": null
}
}
},
"ByteDance2FirstLastFrameNode": {
"description": "Générez une vidéo avec Seedance 2.0 à partir d'une image de première image et, optionnellement, d'une image de dernière image.",
"display_name": "ByteDance Seedance 2.0 Première-Dernière-Image vers Vidéo",
@@ -2633,6 +2676,40 @@
}
}
},
"ConvertArrayToString": {
"display_name": "Convertir un tableau en chaîne",
"inputs": {
"array": {
"name": "tableau"
},
"indent": {
"name": "indentation",
"tooltip": "Espaces par niveau dindentation. 0 produit une chaîne compacte sur une seule ligne."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ConvertDictionaryToString": {
"display_name": "Convertir un dictionnaire en chaîne",
"inputs": {
"dictionary": {
"name": "dictionnaire"
},
"indent": {
"name": "indentation",
"tooltip": "Espaces par niveau dindentation. 0 produit une chaîne compacte sur une seule ligne."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"CosmosImageToVideoLatent": {
"display_name": "CosmosImageVersVidéoLatent",
"inputs": {
@@ -2695,6 +2772,42 @@
}
}
},
"CreateBoundingBoxes": {
"description": "Dessinez des boîtes englobantes sur une toile. Génère des éléments dinvite Ideogram, des boîtes englobantes en espace pixel et une image daperçu.",
"display_name": "Créer des boîtes englobantes",
"inputs": {
"background": {
"name": "arrière-plan",
"tooltip": "Image optionnelle utilisée comme arrière-plan sur la toile et dans laperçu."
},
"editor_state": {
"name": "état_éditeur",
"tooltip": "Dessinez des boîtes englobantes et définissez pour chaque boîte le type, le texte, la description, la palette de couleurs. Commencez par lélément darrière-plan et terminez par le premier plan."
},
"height": {
"name": "hauteur",
"tooltip": "Hauteur de la toile et de la grille de pixels pour les boîtes englobantes."
},
"width": {
"name": "largeur",
"tooltip": "Largeur de la toile et de la grille de pixels pour les boîtes englobantes."
}
},
"outputs": {
"0": {
"name": "aperçu",
"tooltip": null
},
"1": {
"name": "boîtes_englobantes",
"tooltip": null
},
"2": {
"name": "éléments",
"tooltip": null
}
}
},
"CreateCameraInfo": {
"description": "Construit une camera_info. Le mode 'orbit' vise avec les angles de lacet/tangage/distance autour de la cible ; 'look_at' place la caméra à une position dans l'espace mondial. Les coordonnées sont dans l'espace mondial du spectateur (main droite, Y vers le haut).",
"display_name": "Créer des informations de caméra",
@@ -12025,6 +12138,131 @@
}
}
},
"ModelMergeKrea2": {
"display_name": "ModelMergeKrea2",
"inputs": {
"blocks_0_": {
"name": "blocs.0."
},
"blocks_10_": {
"name": "blocs.10."
},
"blocks_11_": {
"name": "blocs.11."
},
"blocks_12_": {
"name": "blocs.12."
},
"blocks_13_": {
"name": "blocs.13."
},
"blocks_14_": {
"name": "blocs.14."
},
"blocks_15_": {
"name": "blocs.15."
},
"blocks_16_": {
"name": "blocs.16."
},
"blocks_17_": {
"name": "blocs.17."
},
"blocks_18_": {
"name": "blocs.18."
},
"blocks_19_": {
"name": "blocs.19."
},
"blocks_1_": {
"name": "blocs.1."
},
"blocks_20_": {
"name": "blocs.20."
},
"blocks_21_": {
"name": "blocs.21."
},
"blocks_22_": {
"name": "blocs.22."
},
"blocks_23_": {
"name": "blocs.23."
},
"blocks_24_": {
"name": "blocs.24."
},
"blocks_25_": {
"name": "blocs.25."
},
"blocks_26_": {
"name": "blocs.26."
},
"blocks_27_": {
"name": "blocs.27."
},
"blocks_2_": {
"name": "blocs.2."
},
"blocks_3_": {
"name": "blocs.3."
},
"blocks_4_": {
"name": "blocs.4."
},
"blocks_5_": {
"name": "blocs.5."
},
"blocks_6_": {
"name": "blocs.6."
},
"blocks_7_": {
"name": "blocs.7."
},
"blocks_8_": {
"name": "blocs.8."
},
"blocks_9_": {
"name": "blocs.9."
},
"first_": {
"name": "premier."
},
"last_": {
"name": "dernier."
},
"model1": {
"name": "model1"
},
"model2": {
"name": "model2"
},
"tmlp_": {
"name": "tmlp."
},
"tproj_": {
"name": "tproj."
},
"txtfusion_layerwise_blocks_0_": {
"name": "txtfusion.layerwise_blocks.0."
},
"txtfusion_layerwise_blocks_1_": {
"name": "txtfusion.layerwise_blocks.1."
},
"txtfusion_projector_": {
"name": "txtfusion.projector."
},
"txtfusion_refiner_blocks_0_": {
"name": "txtfusion.refiner_blocks.0."
},
"txtfusion_refiner_blocks_1_": {
"name": "txtfusion.refiner_blocks.1."
},
"txtmlp_": {
"name": "txtmlp."
}
}
},
"ModelMergeLTXV": {
"display_name": "ModelMergeLTXV",
"inputs": {
@@ -17698,6 +17936,23 @@
}
}
},
"SeedNode": {
"display_name": "Graine",
"inputs": {
"fixed": {
"name": "contrôle après génération"
},
"seed": {
"name": "graine"
}
},
"outputs": {
"0": {
"name": "graine",
"tooltip": null
}
}
},
"SelectCLIPDevice": {
"description": "Place lencodeur de texte CLIP sur un périphérique spécifique (default / cpu / gpu:N).\n\n- « default » restaure le périphérique assigné par le chargeur.\n- « cpu » force le chargement et le déchargement sur le CPU.\n- « gpu:N » force le chargement sur le N-ième GPU disponible.\n\nLorsque le périphérique sélectionné nexiste pas sur la machine actuelle\n(par exemple, un workflow créé sur une machine à 2 GPU ouvert sur une machine à 1 GPU),\nle nœud transmet le CLIP inchangé et enregistre un message\nau lieu déchouer.",
"display_name": "Sélectionner le périphérique CLIP",

View File

@@ -92,6 +92,7 @@
"errorUserTokenAccessDenied": "お使いのAPIトークンにはこのリソースへのアクセス権がありません。トークンの権限を確認してください。",
"errorUserTokenInvalid": "保存されているAPIトークンが無効または期限切れです。設定でトークンを更新してください。",
"failedToCreateNode": "ノードの作成に失敗しました。再試行するか、詳細はコンソールをご確認ください。",
"failedToSetModelValue": "ノードは追加されましたが、モデルを自動設定できませんでした。詳細はコンソールをご確認ください。",
"fileFormats": "ファイル形式",
"fileName": "ファイル名",
"fileSize": "ファイルサイズ",
@@ -241,7 +242,8 @@
"auth/user-not-found": "このメールアドレスに紐づくアカウントが見つかりません。新しいアカウントを作成しますか?",
"auth/weak-password": "パスワードが弱すぎます。6文字以上のより強力なパスワードを使用してください。",
"auth/wrong-password": "入力されたパスワードが正しくありません。もう一度お試しください。",
"generic": "サインイン中に問題が発生しました。もう一度お試しください。"
"generic": "サインイン中に問題が発生しました。もう一度お試しください。",
"signupBlocked": "現在アカウントを作成できません。しばらくしてから再度お試しください。繰り返し発生する場合は support@comfy.org までご連絡ください。"
},
"login": {
"andText": "および",
@@ -323,6 +325,11 @@
"signUpWithGithub": "Githubでサインアップ",
"signUpWithGoogle": "Googleでサインアップ",
"title": "アカウントを作成する"
},
"turnstile": {
"expired": "認証の有効期限が切れました。再度チャレンジを完了してください。",
"failed": "認証に失敗しました。もう一度お試しください。",
"submitBlockedHint": "上記の認証チャレンジを完了すると、サインアップが有効になります。"
}
},
"batch": {
@@ -346,6 +353,17 @@
"x": "X",
"y": "Y"
},
"boundingBoxes": {
"clearAll": "すべてクリア",
"clickRegionToEdit": "編集する領域をクリックしてください。",
"colors": "カラーパレット",
"descLabel": "説明",
"descPlaceholder": "この領域の説明",
"textLabel": "テキスト",
"textPlaceholder": "表示するテキスト(そのまま)",
"typeObj": "obj",
"typeText": "テキスト"
},
"breadcrumbsMenu": {
"app": "アプリ",
"blueprint": "ブループリント",
@@ -755,6 +773,13 @@
"creditsAvailable": "利用可能なクレジット",
"details": "詳細",
"eventType": "イベントタイプ",
"eventTypes": {
"accountCreated": "アカウント作成",
"apiNodeUsage": "パートナーノード利用",
"apiUsage": "API利用",
"creditAdded": "クレジット追加",
"gpuUsage": "GPU利用"
},
"faqs": "よくある質問",
"invoiceHistory": "請求履歴",
"lastUpdated": "最終更新",
@@ -811,6 +836,7 @@
},
"dataTypes": {
"*": "*",
"ARRAY": "配列",
"AUDIO": "オーディオ",
"AUDIO_ENCODER": "オーディオエンコーダ",
"AUDIO_ENCODER_OUTPUT": "オーディオエンコーダ出力",
@@ -818,11 +844,13 @@
"BACKGROUND_REMOVAL": "背景除去",
"BOOLEAN": "ブール",
"BOUNDING_BOX": "バウンディングボックス",
"BOUNDING_BOXES": "バウンディングボックス",
"CAMERA_CONTROL": "カメラコントロール",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
"COLOR": "カラー",
"COLORS": "色",
"COMBO": "コンボ",
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
@@ -832,6 +860,7 @@
"CURVE": "カーブ",
"DA3_GEOMETRY": "DA3_GEOMETRY",
"DA3_MODEL": "DA3_MODEL",
"DICT": "辞書",
"ELEVENLABS_VOICE": "ELEVENLABS_VOICE",
"FACE_DETECTION_MODEL": "顔検出モデル",
"FACE_LANDMARKS": "FACE_LANDMARKS",
@@ -1830,6 +1859,35 @@
"zoomOptions": "ズームオプション",
"zoomOut": "縮小"
},
"hdrViewer": {
"channel": "チャンネル",
"channels": {
"a": "アルファ",
"b": "B",
"g": "G",
"luminance": "輝度",
"r": "R",
"rgb": "RGB"
},
"clipWarnings": "クリップ警告",
"dither": "ディザ",
"exposure": "露出",
"failedToLoad": "HDR画像の読み込みに失敗しました",
"fitView": "フィット",
"hdrImage": "HDR画像",
"histogram": "ヒストグラム",
"inf": "Inf",
"max": "最大",
"mean": "平均",
"min": "最小",
"nan": "NaN",
"normalizeExposure": "自動露出",
"openInHdrViewer": "HDRビューアで開く",
"resolution": "解像度",
"sourceGamut": "ソースガマット",
"stdDev": "標準偏差",
"title": "HDRビューア"
},
"help": {
"helpCenterMenu": "ヘルプセンターメニュー",
"recentReleases": "最近のリリース"
@@ -2957,6 +3015,10 @@
"uploadError": "ペインター画像のアップロードに失敗しました: {status} - {statusText}",
"width": "幅"
},
"palette": {
"addColor": "色を追加",
"swatchTitle": "クリックで編集 · ドラッグで並べ替え · 右クリックで削除"
},
"progressToast": {
"allDownloadsCompleted": "すべてのダウンロードが完了しました",
"downloadingModel": "モデルをダウンロード中...",
@@ -3041,7 +3103,6 @@
"color": "ノードカラー",
"editSubgraph": "サブグラフを編集",
"editTitle": "タイトルを編集",
"enterSubgraph": "サブグラフに入る",
"errorHelp": "詳細なヘルプについては、{github} または {support} をご利用ください",
"errorHelpGithub": "GitHub イシューを提出",
"errorHelpSupport": "サポートに連絡",
@@ -3661,6 +3722,10 @@
"addApiCredits": "APIクレジットを追加",
"addCredits": "クレジットを追加",
"addCreditsLabel": "いつでもクレジット追加可能",
"additionalCredits": "追加クレジット",
"additionalCreditsInUse": "使用中",
"additionalCreditsInfo": "追加クレジットについて",
"additionalCreditsTooltip": "プランに追加して購入したクレジット。月間クレジットがなくなった後に使用されます。購入から1年後に有効期限が切れます。",
"benefits": {
"benefit1": "パートナーノード用月間クレジット — 必要に応じて追加購入可能",
"benefit1FreeTier": "毎月のクレジット増加、いつでもチャージ可能",
@@ -3682,27 +3747,55 @@
"keepSubscription": "サブスクリプションを維持する",
"title": "サブスクリプションのキャンセル"
},
"cancelSubscription": "サブスクリプションをキャンセル",
"cancelPlan": "プランをキャンセル",
"cancelSuccess": "サブスクリプションが正常にキャンセルされました",
"canceled": "キャンセル済み",
"canceledCard": {
"description": "今後請求されることはありません。{date}まで機能は有効です。",
"title": "サブスクリプションはキャンセルされました"
},
"changePlan": "プランを変更",
"changeTo": "{plan}に変更",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud ロゴ",
"contactOwnerToSubscribe": "サブスクリプションのためにワークスペースのオーナーに連絡してください",
"contactUs": "お問い合わせ",
"creditSliderSave": "{percent}%{amount})を節約",
"creditsLeftOfTotal": "{total} 中 {remaining} 残り",
"creditsRemainingThisMonth": "今月残りのクレジット",
"creditsRemainingThisYear": "今年残りのクレジット",
"creditsUsed": "{used} 使用済み",
"creditsYouveAdded": "追加したクレジット",
"currentPlan": "現在のプラン",
"customLoRAsLabel": "独自のLoRAをインポート",
"description": "あなたに最適なプランを選択してください",
"descriptionWorkspace": "ワークスペースに最適なプランを選択してください",
"downgrade": {
"body": "このワークスペースの他のメンバーはすぐに削除されます。",
"confirm": "プランを変更",
"confirmationPhrase": "理解しました",
"confirmationPrompt": "確認のため「{phrase}」と入力してください。",
"failed": "プランの変更に失敗しました",
"failedAfterMemberRemoval": "チームメンバーは削除されましたが、プランの変更が完了しませんでした。再度お試しいただくか、サポートまでご連絡ください。",
"memberRemovalFailed": "{email} をチームから削除できませんでした。一部のメンバーはすでに削除されている可能性があり、プランは変更されませんでした。",
"notAllowed": "このプラン変更は利用できません",
"paymentMethodRequired": "プランを変更するにはお支払い方法が必要です",
"paymentPageBlocked": "お支払いページを開けませんでした。再度お試しください。",
"title": "{plan}プランに変更しますか?"
},
"endsOnDate": "{date}に終了します",
"enterprise": {
"cta": "詳細を見る",
"flexibility": "より柔軟な対応やカスタム機能をご希望ですか?",
"name": "エンタープライズ",
"needMoreMembers": "さらに多くのメンバーが必要ですか?",
"reachOut": "ぜひご連絡ください。お打ち合わせを調整いたします。"
},
"everythingInPlus": "{plan}のすべて、さらに:",
"expiresDate": "{date} に期限切れ",
"freePerks": {
"maxRuntime": "最大実行時間:{duration}"
},
"freeTier": {
"description": "無料プランには、Comfy Cloudをお試しいただける毎月{credits}クレジットが含まれています。",
"descriptionGeneric": "無料プランには、Comfy Cloudをお試しいただける毎月のクレジット枠が含まれています。",
@@ -3730,7 +3823,7 @@
"inviteUpTo": "最大 {count} 人を招待",
"invoiceHistory": "請求履歴",
"learnMore": "詳細を見る",
"managePayment": "支払いを管理",
"manageBilling": "請求管理",
"managePlan": "プランを管理",
"manageSubscription": "サブスクリプションを管理",
"maxDuration": {
@@ -3744,50 +3837,99 @@
"maxMembersLabel": "最大メンバー数",
"member": "メンバー",
"memberCount": "{count}名のメンバー",
"membersLabel": "{count}名までのメンバー",
"messageSupport": "サポートに連絡",
"monthly": "月額",
"monthlyBonusDescription": "月間クレジットボーナス",
"monthlyCredits": "月間クレジット",
"monthlyCreditsInfo": "これらのクレジットは毎月リフレッシュされ、繰り越しはできません",
"monthlyCreditsLabel": "月間クレジット",
"monthlyCreditsPerMemberLabel": "月間クレジット / メンバー",
"monthlyCreditsRollover": "これらのクレジットは翌月に繰り越されます",
"monthlyCreditsUsedUpDescription": "現在、追加クレジットが消費されています。",
"monthlyCreditsUsedUpTitle": "月間クレジットが使い切られました。{date}に補充されます",
"monthlyCreditsUsedUpTitleNoDate": "月間クレジットが使い切られました",
"monthlyUsageProgress": "月間クレジット {total} 中 {used} 使用済み",
"mostPopular": "最も人気",
"needTeamWorkspace": "チームワークスペースが必要ですか?",
"nextBillingCycle": "次の請求サイクル",
"nextMonthInvoice": "翌月の請求書",
"outOfCreditsDescription": "生成を続けるにはクレジットを追加してください。",
"outOfCreditsTitle": "クレジットがありません。{date}に補充されます",
"outOfCreditsTitleNoDate": "クレジットがありません",
"partnerNodesBalance": "\"パートナーノード\" クレジット残高",
"partnerNodesCredits": "パートナーノードクレジット",
"partnerNodesDescription": "商用/独自モデルの実行用",
"partnerNodesPricingTable": "パートナーノードの料金表",
"perMonth": "USD / 月",
"personalHeader": "個人プランは個人利用専用です。{action}",
"personalHeaderAction": "チームメンバーを追加するには、チームプランにご加入ください。",
"personalWorkspace": "個人ワークスペース",
"planLoadError": "プランの詳細を読み込めませんでした。",
"planLoadErrorRetry": "再試行",
"planScope": {
"personal": "個人向け",
"team": "チーム向け"
},
"plansAndPricing": "プランと価格",
"plansForWorkspace": "{workspace} のプラン",
"prepaidCreditsInfo": "別途購入した有効期限のないクレジット",
"prepaidDescription": "プリペイドクレジット",
"preview": {
"addCreditCard": "クレジットカードを追加",
"afterThat": "その後",
"backToAllPlans": "すべてのプランに戻る",
"billedEachMonth": "毎月{amount}請求されます。いつでもキャンセル可能です。",
"commitment": "契約期間",
"confirm": "確認",
"confirmChange": "変更を確認",
"confirmChangeTitle": "予定されている変更を確認",
"confirmPayment": "お支払いを確認",
"confirmPlanChange": "プラン変更を確認",
"confirmUpgradeCta": "アップグレードを確認",
"confirmUpgradeTitle": "アップグレードの確認",
"creditFromCurrent": "現在の{plan}からのクレジット",
"creditsRefillMonthlyTo": "クレジットは毎月{amount}に補充されます",
"creditsRefillTo": "クレジットが補充されます:",
"creditsYoullGetToday": "本日付与されるクレジット",
"currentMonthly": "月間プラン",
"eachMonthCreditsRefill": "毎月クレジットが補充されます",
"eachYearCreditsRefill": "毎年クレジットが補充されます",
"ends": "{date}に終了",
"everyMonthStarting": "毎月{date}から開始",
"hideFeatures": "機能を隠す",
"newMonthlySubscription": "新しい月間サブスクリプション",
"nextPaymentDue": "次回のお支払いは{date}です。いつでもキャンセルできます。",
"paymentPopupBlocked": "支払いページを開けませんでした。ポップアップを許可して再試行してください。",
"perMember": "/ メンバー",
"privacyPolicy": "プライバシーポリシー",
"proratedCharge": "{plan}の按分請求",
"proratedRefund": "{plan}の按分返金",
"refillReplacesNote": "月間補充を置き換えます。既存の残高は保持されます。",
"showMoreFeatures": "さらに機能を表示",
"starting": "{date}から開始",
"startingToday": "本日から開始",
"startsOn": "{date}に開始",
"stayOnUntil": "{date}まで{plan}が継続されます。",
"subscribeToPlan": "{plan}に加入",
"switchToPlan": "{plan}に切り替え",
"switchesToday": "本日の切り替え",
"terms": "利用規約",
"termsAgreement": "続行することで、Comfy Orgの{terms}および{privacy}に同意したものとみなされます。",
"totalDueToday": "本日のお支払い合計"
"totalDueToday": "本日のお支払い合計",
"yearlySubscription": "年間サブスクリプション",
"youllBeCharged": "ご請求額:"
},
"pricingBlurb": "*このテンプレートに基づきます。{seeDetails}。{questions}や{enterpriseDiscussions}についてはお問い合わせください。詳細な料金については{clickHere}。",
"pricingBlurbClickHere": "こちらをクリック",
"pricingBlurbEnterprise": "エンタープライズのご相談",
"pricingBlurbQuestions": "ご質問",
"pricingBlurbSeeDetails": "詳細を見る",
"reactivatePlan": "プランを再開",
"refillsDate": "{date}に補充",
"refillsNextCycle": "次のサイクルで補充",
"refreshCredits": "クレジットを更新",
"remaining": "残り",
"renewsDate": "{date} に更新",
"renewsOnDate": "{date}に更新されます",
"required": {
"pollingFailed": "サブスクリプションの有効化に失敗しました",
"pollingSuccess": "サブスクリプションが有効化されました!",
@@ -3799,7 +3941,11 @@
"resubscribe": "再購読する",
"resubscribeSuccess": "サブスクリプションが再開されました",
"resubscribeTo": "{plan}を再購読する",
"saveYearly": "20%お得",
"saveYearlyUpTo": "最大20%お得",
"soloUseOnly": "個人利用のみ",
"subscribe": "購読する",
"subscribeFailed": "購読に失敗しました",
"subscribeForMore": "アップグレード",
"subscribeNow": "今すぐ購読",
"subscribeTo": "{plan}に登録",
@@ -3807,10 +3953,46 @@
"subscribeToRun": "購読する",
"subscribeToRunFull": "実行を購読",
"subscriptionRequiredMessage": "クラウドでワークフローを実行するにはメンバーにサブスクリプションが必要です",
"success": {
"allSet": "準備が整いました",
"inviteEmailsPlaceholder": "カンマ区切りでメールアドレスを入力",
"inviteSubtext": "設定から後で招待することもできます",
"inviteTitle": "チームを招待",
"planUpdated": "プランが正常に更新されました。",
"receiptEmailed": "領収書をメールで送信しました。",
"sendInvites": "招待を送信"
},
"teamHeader": "コラボレーションを希望するチーム向け。さらに多くのメンバーが必要ですか?{learnMore}(エンタープライズについて)。",
"teamHeaderLearnMore": "詳細はこちら",
"teamPerks": {
"concurrentRuns": "メンバーはワークフローを同時に実行可能",
"inviteMembers": "メンバーを招待",
"rolePermissions": "ロールベースの権限",
"sharedCreditPool": "全メンバーで共有するクレジットプール"
},
"teamPlan": {
"changePlan": "プランを変更",
"comingSoonLabel": "近日公開:",
"cta": "チーム年間プランに加入",
"ctaMonthly": "チーム月間プランに加入",
"currentPlan": "現在のプラン",
"detailsTitle": "詳細",
"name": "チームプラン",
"perkConcurrentRuns": "メンバーはワークフローを同時に実行可能",
"perkInviteMembers": "チームメンバーを招待",
"perkProjectAssets": "プロジェクト・アセット管理",
"perkRolePermissions": "ロールベースの権限管理",
"perkSharedPool": "全メンバーで共有するクレジットプール",
"tagline": "月間クレジット数を自由に選択。多くのクレジットでさらに割引。",
"unavailable": "このチームプランは現在ご利用いただけません。"
},
"teamPlanIncludes": "{plan}のすべてに加えて、以下が含まれます:",
"teamPlanName": "チーム",
"teamWorkspace": "チームワークスペース",
"tierNameYearly": "{name} 年間",
"tiers": {
"creator": {
"feature1": "独自モデルのインポート",
"name": "クリエイター"
},
"founder": {
@@ -3820,9 +4002,12 @@
"name": "無料"
},
"pro": {
"feature1": "より長いワークフロー実行時間最大1時間",
"name": "プロ"
},
"standard": {
"feature1": "最大30分のワークフロー実行時間",
"feature2": "いつでもクレジットを追加可能",
"name": "スタンダード"
}
},
@@ -3835,6 +4020,8 @@
"upgradeToAddCredits": "クレジット追加のためアップグレード",
"usdPerMonth": "USD / 月",
"usdPerMonthPerMember": "USD / 月 / メンバー",
"usedAfterMonthly": "月間クレジットがなくなった後に使用",
"videoEstimate": "約{count}本の5秒動画を生成*",
"videoEstimateExplanation": "これらの見積もりは、Wan 2.2 画像から動画テンプレートのデフォルト設定5秒、640x640、16fps、4ステップサンプリングに基づいています。",
"videoEstimateHelp": "このテンプレートの詳細",
"videoEstimateLabel": "Wan 2.2 画像から動画テンプレートで生成される約5秒動画数",
@@ -3844,10 +4031,10 @@
"viewMoreDetails": "詳細を表示",
"viewMoreDetailsPlans": "プランと価格の詳細を見る",
"viewUsageHistory": "利用履歴を表示",
"whatsIncluded": "含まれる内容:",
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
"yearly": "年額",
"yearlyCreditsLabel": "年間合計クレジット",
"yearlyDiscount": "20%割引",
"yourPlanIncludes": "ご利用プランに含まれるもの:"
},
"tabMenu": {
@@ -4138,6 +4325,19 @@
}
},
"workspacePanel": {
"changeRoleDialog": {
"demoteConfirm": "メンバーに降格",
"demoteMessage": "管理者権限が失われます。",
"demoteTitle": "{name} をメンバーに降格しますか?",
"error": "役割の更新に失敗しました",
"promoteConfirm": "オーナーにする",
"promoteIntro": "次のことができるようになります:",
"promotePermissionCredits": "追加クレジットの付与",
"promotePermissionManage": "メンバー、支払い方法、ワークスペース設定の管理",
"promotePermissionRoles": "他のオーナーの昇格・降格(ワークスペース作成者を除く)",
"promoteTitle": "{name} をオーナーにしますか?",
"success": "役割が更新されました"
},
"createWorkspaceDialog": {
"create": "作成",
"message": "ワークスペースはメンバーでクレジットプールを共有できます。作成後、あなたがオーナーになります。",
@@ -4162,17 +4362,11 @@
"inviteLimitReached": "メンバーの上限50人に達しました",
"inviteMember": "メンバーを招待",
"inviteMemberDialog": {
"createLink": "リンクを作成",
"linkCopied": "コピーしました",
"linkCopyFailed": "コピーに失敗しました",
"linkStep": {
"copyLink": "リンクをコピー",
"done": "完了",
"message": "相手のアカウントがこのメールアドレスを使用していることを確認してください。",
"title": "このリンクを相手に送信してください"
},
"message": "共有可能な招待リンクを作成して送信してください",
"failedCount": "{count} 件の招待を送信できませんでした。再試行してください。",
"invalidEmailCount": "{count} 件の無効なメールアドレス",
"invitedMessage": "{emails} に招待を送信しました",
"placeholder": "招待する人のメールアドレスを入力",
"seatLimitReached": "最大 {count} 名のチームメンバーを招待できます。",
"title": "このワークスペースに人を招待"
},
"inviteUpsellDialog": {
@@ -4180,8 +4374,7 @@
"messageSingleSeat": "Standardプランにはワークスペースオーナー1名分のシートが含まれています。追加メンバーを招待するには、Creatorプラン以上にアップグレードして複数シートを有効にしてください。",
"titleNotSubscribed": "メンバーを招待するにはサブスクリプションが必要です",
"titleSingleSeat": "現在のプランは1名のみ対応しています",
"upgradeToCreator": "Creatorにアップグレード",
"viewPlans": "プランを見る"
"upgradeToTeam": "チームにアップグレード"
},
"leaveDialog": {
"leave": "退出",
@@ -4190,30 +4383,35 @@
},
"members": {
"actions": {
"copyLink": "招待リンクをコピー",
"cancelInvite": "招待をキャンセル",
"changeRole": "役割を変更",
"removeMember": "メンバーを削除",
"revokeInvite": "招待を取り消す"
"resendInvite": "招待を再送信"
},
"columns": {
"expiryDate": "有効期限",
"inviteDate": "招待日",
"joinDate": "参加日"
"role": "役割"
},
"createNewWorkspace": "新しいワークスペースを作成してください。",
"contactUs": "お問い合わせ",
"header": "メンバー",
"membersCount": "{count}/50人のメンバー",
"needMoreMembers": "さらにメンバーが必要ですか?",
"noInvites": "保留中の招待はありません",
"noMembers": "メンバーがいません",
"pendingInvitesCount": "{count}件の招待保留中",
"personalWorkspaceMessage": "現在、個人用ワークスペースには他のメンバーを招待できません。メンバーを追加するには、",
"reactivateTeam": "チームを再有効化",
"searchPlaceholder": "検索...",
"tabs": {
"active": "アクティブ",
"pendingCount": "保留中({count}"
},
"upsellBannerSubscribe": "このワークスペースにチームメンバーを招待するには、Creatorプラン以上のご契約が必要です。",
"upsellBannerUpgrade": "追加のチームメンバーを招待するには、Creatorプラン以上にアップグレードしてください。",
"viewPlans": "プランを見る"
"upgradeToTeam": "チームにアップグレード",
"upsellBanner": "チームメンバーを追加するには、プランをアップグレードしてください。",
"upsellBannerReactivate": "さらにメンバーを追加するには、プランを再有効化してください。"
},
"menu": {
"creatorCannotLeave": "ワークスペースの作成者は、自分が作成したワークスペースを退出できません",
"deleteWorkspace": "ワークスペースを削除",
"deleteWorkspaceDisabledTooltip": "まずワークスペースの有効なサブスクリプションをキャンセルしてください",
"editWorkspace": "ワークスペースの詳細を編集",
@@ -4242,6 +4440,8 @@
"failedToFetchWorkspaces": "ワークスペースの読み込みに失敗しました",
"failedToLeaveWorkspace": "ワークスペースの退出に失敗しました",
"failedToUpdateWorkspace": "ワークスペースの更新に失敗しました",
"inviteResendFailed": "招待の再送信に失敗しました",
"inviteResent": "招待を再送信しました",
"workspaceCreated": {
"message": "プランに加入し、チームメイトを招待して、コラボレーションを始めましょう。",
"subscribe": "プランに加入",

Some files were not shown because too many files have changed in this diff Show More