Compare commits

...

32 Commits

Author SHA1 Message Date
Terry Jia
5b3bc77926 call test 2025-11-10 20:25:20 -05:00
BunnyAI
c94cedf8ee add 'SaveVideo' into saveNodeTypes (#6647)
## Summary

add 'SaveVideo' into saveNodeTypes to fix [ComfyUI
10285](https://github.com/comfyanonymous/ComfyUI/issues/10285) and
[ComfyUI 10479](https://github.com/comfyanonymous/ComfyUI/issues/10479)

## Changes

add 'SaveVideo' into saveNodeTypes values

## Review Focus

is this enough?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6647-add-SaveVideo-into-saveNodeTypes-2a76d73d365081d9bb7eccc358c9836d)
by [Unito](https://www.unito.io)
2025-11-10 19:47:33 -05:00
Comfy Org PR Bot
ba355b543d 1.32.4 (#6641)
Patch version increment to 1.32.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6641-1-32-4-2a56d73d365081a8abaaeca5d7473390)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-08 12:24:08 -07:00
Jin Yi
e9b641cfb7 [bugfix] Fix Storybook _sfc_main undefined error after v1.29.3 (#6635)
## Summary

Fixes Storybook rendering issue where all components fail to load with
`_sfc_main is not defined` error in **local development** since v1.29.3.

## Problem

After upgrading to v1.29.3, all Storybook components fail to render **in
local development** (`pnpm storybook`) with the following error:

```
ReferenceError: _sfc_main is not defined
The component failed to render properly, likely due to a configuration issue in Storybook.
```

**Important**: This issue only affects **local development**
environments. The deployed/built Storybook works correctly.

This affects both:
- Main Storybook (`pnpm storybook`)
- Desktop-ui Storybook instances

## Root Cause

In v1.29.3, commit `64430708e` ("perf: tree shaking and minify #6068")
enabled build optimizations in `vite.config.mts`:

```typescript
// Before (v1.29.2)
rollupOptions: {
  treeshake: false
}
esbuild: {
  minifyIdentifiers: false
}

// After (v1.29.3)
rollupOptions: {
  treeshake: true  // ⚠️ Enabled
}
esbuild: {
  minifyIdentifiers: SHOULD_MINIFY  // ⚠️ Conditionally enabled
}
```

While these optimizations are beneficial for production builds, they
cause issues in **Storybook's local dev server**:

1. **Tree-shaking in dev mode**: Rollup incorrectly identifies Vue SFC's
`_sfc_main` exports as unused code during the dev server's module
transformation
2. **Identifier minification**: esbuild minifies `_sfc_main` to shorter
names in development, breaking Storybook's HMR (Hot Module Replacement)
and dynamic module loading

Since Storybook's `main.ts` inherits settings from `vite.config.mts` via
`mergeConfig`, these optimizations were applied to Storybook's dev
server configuration, causing Vue components to fail rendering in local
development.

**Why deployed Storybook works**: Production builds have different
optimization pipelines that handle Vue SFCs correctly, but the dev
server's real-time transformation breaks with these settings.

## Solution

Added explicit build configuration overrides in both Storybook
configurations to ensure the **dev server** doesn't inherit problematic
optimizations:

**Files changed:**
- `.storybook/main.ts`
- `apps/desktop-ui/.storybook/main.ts`

**Changes:**
```typescript
esbuild: {
  // Prevent minification of identifiers to preserve _sfc_main in dev mode
  minifyIdentifiers: false,
  keepNames: true
},
build: {
  rollupOptions: {
    // Disable tree-shaking for Storybook dev server to prevent Vue SFC exports from being removed
    treeshake: false,
    // ... existing onwarn config
  }
}
```

This ensures Storybook's **local development server** prioritizes
stability and debuggability over bundle size optimization, while
production builds continue to benefit from tree-shaking and
minification.

## Testing

1. Cleared Storybook and Vite caches: `rm -rf .storybook/.cache
node_modules/.vite`
2. Started local Storybook dev server with `pnpm storybook`
3. Verified all component stories render correctly without `_sfc_main`
errors
4. Ran `pnpm typecheck` to ensure TypeScript compilation succeeds
5. Tested HMR (Hot Module Replacement) works correctly with component
changes

## Context

- This is a **local development-only** issue; deployed Storybook builds
work fine
- Storybook dev server requires special handling because it dynamically
imports and hot-reloads all stories at runtime
- Vue SFC compilation generates `_sfc_main` as an internal identifier
that must be preserved during dev transformations
- Development tools like Storybook benefit from unoptimized builds for
better debugging, HMR, and stability
- Production builds remain optimized with tree-shaking and minification
enabled

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6635-bugfix-Fix-Storybook-_sfc_main-undefined-error-after-v1-29-3-2a56d73d36508194a25eef56789e5e2b)
by [Unito](https://www.unito.io)
2025-11-08 10:40:33 -07:00
Christian Byrne
56153596d9 fix: release summary comment action (#6640)
Fixes
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/19188216288/job/54858802407

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6640-fix-release-summary-comment-action-2a56d73d365081f89bf1ec7c4e4818a4)
by [Unito](https://www.unito.io)
2025-11-08 10:39:46 -07:00
Christian Byrne
b679bfe8f8 logging: log context on session storage write error (QuotaError) (#6636)
adds some context to the storage write errors observed in telemetry data
- in order to pinpoint the exact cause.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6636-logging-log-context-on-session-storage-write-error-QuotaError-2a56d73d365081c28bb0dbfdfef8395a)
by [Unito](https://www.unito.io)
2025-11-08 10:19:12 -07:00
Johnpaul Chiwetelu
54979701d0 Cloud feedback Extension (#6626)
This pull request introduces a new extensible system for adding custom
action bar buttons to the top menu, and demonstrates its use by adding a
cloud feedback button. The changes include defining a new
`ActionBarButton` type, updating the extension system to support action
bar buttons, creating a Pinia store to aggregate buttons from
extensions, and updating the UI to render these buttons in the top menu.
The cloud feedback button is now conditionally loaded for cloud
environments.

**Extensible Action Bar Button System**

* Defined a new `ActionBarButton` interface in `comfy.ts` for describing
buttons (icon, label, tooltip, class, click handler) and added an
`actionBarButtons` property to the `ComfyExtension` interface to allow
extensions to contribute buttons.
[[1]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca
<img width="1134" height="437" alt="Screenshot 2025-11-07 at 01 07 36"
src="https://github.com/user-attachments/assets/36b6145a-0b82-4f7d-88e8-f2ea350a359b"
/>
8ec269876c2cf3948f867d08c14032c04d66R60-R82)
[[2]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca8ec269876c2cf3948f867d08c14032c04d66R128-R131)
* Created a Pinia store (`actionBarButtonStore.ts`) that collects all
action bar buttons from registered extensions for use in the UI.

**UI Integration**

* Added a new `ActionBarButtons.vue` component that renders all action
bar buttons using the store, and integrated it into the top menu section
(`TopMenuSection.vue`).
[[1]](diffhunk://#diff-d6820f57cde865083d515ac0c23e1ad09e6fbc6792d742ae948a1d3b772be473R1-R28)
[[2]](diffhunk://#diff-45dffe390927ed2d5ba2426a139c52adae28ce15f81821c88e7991944562074cR10)
[[3]](diffhunk://#diff-45dffe390927ed2d5ba2426a139c52adae28ce15f81821c88e7991944562074cR28)

**Cloud Feedback Button Extension**

* Implemented a new extension (`cloudFeedbackTopbarButton.ts`) that
registers a "Feedback" button opening the Zendesk feedback page, and
ensured it loads only in cloud environments.
[[1]](diffhunk://#diff-b84a6a0dfaca19fd77b3fae6999a40c3ab8d04ed22636fcdecc65b385a8e9a98R1-R24)
[[2]](diffhunk://#diff-236993d9e4213efe96d267c75c3292d32b93aa4dd6c3318d26a397e0ae56bc87R32)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6626-Cloud-feedback-Extension-2a46d73d36508170bd07f582ccfabb3c)
by [Unito](https://www.unito.io)
2025-11-08 12:36:04 +01:00
Comfy Org PR Bot
64e704c2f9 1.32.3 (#6634)
Patch version increment to 1.32.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6634-1-32-3-2a56d73d365081c7ad4bda4aba8b4076)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-07 21:50:23 -07:00
Benjamin Lu
b1050e3195 Fix session cookie creation race: skip initial token refresh, wrap extension auth hooks (#6563)
Fixes a race causing “No auth header available for session creation”
during sign‑in, by skipping the initial token refresh event, and
wrapping extension auth hooks with async error handling.

Sentry:
https://comfy-org.sentry.io/issues/6990347926/?alert_rule_id=1614600&project=4509681221369857

Context
- Error surfaced as an unhandled rejection when session creation was
triggered without a valid auth header.
- Triggers: both onAuthUserResolved and onAuthTokenRefreshed fired
during initial login.
- Pre‑fix, onIdTokenChanged treated the very first token emission as a
“refresh” as well, so two concurrent createSession() calls ran
back‑to‑back.
- One of those calls could land before a Firebase ID token existed, so
getAuthHeader() returned null → createSession threw “No auth header
available for session creation”.

Exact pre‑fix failure path
- src/extensions/core/cloudSessionCookie.ts
  - onAuthUserResolved → useSessionCookie().createSession()
  - onAuthTokenRefreshed → useSessionCookie().createSession()
- src/stores/firebaseAuthStore.ts
- onIdTokenChanged increments tokenRefreshTrigger even for the initial
token (treated as a refresh)
- getAuthHeader() → getIdToken() may be undefined briefly during
initialization
- src/platform/auth/session/useSessionCookie.ts
- createSession(): calls authStore.getAuthHeader(); if falsy, throws
Error('No auth header available for session creation')

What this PR changes
1) Skip initial token “refresh”
- Track lastTokenUserId and ignore the first onIdTokenChanged for a
user; only subsequent token changes count as refresh events.
   - File: src/stores/firebaseAuthStore.ts
2) Wrap extension auth hooks with async error handling
- Use wrapWithErrorHandlingAsync for
onAuthUserResolved/onAuthTokenRefreshed/onAuthUserLogout callbacks to
avoid unhandled rejections.
   - File: src/services/extensionService.ts

Result
- Eliminates the timing window where createSession() runs before
getIdToken() returns a token.
- Ensures any remaining errors are caught and reported instead of
surfacing as unhandled promise rejections.

Notes
- Lint and typecheck run clean (pnpm lint:fix && pnpm typecheck).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6563-Fix-session-cookie-creation-race-dedupe-calls-skip-initial-token-refresh-wrap-extensio-2a16d73d365081ef8c22c5ac8cb948aa)
by [Unito](https://www.unito.io)
2025-11-07 21:30:49 -07:00
Benjamin Lu
ba100c4a04 Hide browser tab star when autosave is enabled (#6568)
- Hide "*" indicator in the browser tab title when autosave is enabled
(Comfy.Workflow.AutoSave === 'after delay').
- Refactor: extract readable computed values
(`shouldShowUnsavedIndicator`, `isActiveWorkflowModified`,
`isActiveWorkflowPersisted`).
- Aligns with workflow tab behavior; also hides while Shift is held
(matches in-app tab logic).

Files touched:
- src/composables/useBrowserTabTitle.ts

Validation:
- Ran `pnpm lint:fix` and `pnpm typecheck` — both passed.

Manual test suggestions:
- With autosave set to 'after delay': modify a workflow → browser tab
should not show `*`.
- With autosave 'off': modify or open non-persisted workflow → browser
tab shows `*`.
- Hold Shift: indicator hidden while held (consistent with workflow
tab).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6568-Hide-browser-tab-star-when-autosave-is-enabled-refactor-title-logic-2a16d73d365081549906e9d1fed07a42)
by [Unito](https://www.unito.io)
2025-11-07 21:30:34 -07:00
Christian Byrne
cafd2de961 ci: comment when publish to npm/pypi finishes successfully (#6628)
This change adds a reusable `post-release-summary` composite action that
automatically figures out the current/previous version, generates diff +
PyPI/npm links, and posts (or updates) the release summary comment
whenever the publish jobs succeed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6628-ci-comment-when-publish-to-npm-pypi-finishes-successfully-2a46d73d36508181a8d0eb050efe7762)
by [Unito](https://www.unito.io)
2025-11-07 20:55:51 -07:00
Christian Byrne
1f3fb90b1b increase tracking heartbeat interval from 30sec to 5min (#6631)
This event is taking up too much of the quota, increase the interval for
now.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6631-increase-tracking-heartbeat-interval-from-30sec-to-5min-2a46d73d3650814291c4fae50227d4ec)
by [Unito](https://www.unito.io)
2025-11-07 00:05:38 -07:00
Christian Byrne
27afd01297 add language selector to desktop onboarding views (#6591)
Allows changing language during desktop onboarding

<img width="3816" height="2045" alt="Selection_2231"
src="https://github.com/user-attachments/assets/b8a0dda3-70e7-42a9-96f1-10d00e2fd85c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6591-add-language-selector-to-desktop-onboarding-views-2a26d73d365081f58d00dca2b2759d82)
by [Unito](https://www.unito.io)
2025-11-06 22:17:21 -07:00
Christian Byrne
535f857330 refactor: move renderer-dependent utils into workbench scope (#6621)
This PR cleans up the base-layer utilities so they no longer pull
renderer or workbench code. The renderer-only `isPrimitiveNode` guard
now lives in `src/renderer/utils/nodeTypeGuards.ts`, and the node
help/model/ordering helpers have moved into `src/workbench/utils`. All
affected services, stores, scripts, and tests were updated to import
from the new locations.

The idea is to reduce the number of Base→Renderer/Base→Workbench edges
(higher scoped base/common utils should not import from
renderer/workbench layers).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6621-refactor-move-renderer-dependent-utils-into-workbench-scope-2a36d73d36508167aff0fc8a22202d7f)
by [Unito](https://www.unito.io)
2025-11-06 19:32:41 -07:00
Marwan Ahmed
adb15aac40 feat: pre-fill user info in Zendesk support link (#6586)
Add user email and ID as URL parameters when opening the Contact Support
link to improve support experience. Only includes user data when logged
in.

## Summary

Enhanced the Contact Support command to automatically pre-fill user
email and ID in Zendesk support tickets, streamlining the support
request process for authenticated users.

## Changes

- **What**: 
- Added `useCurrentUser` composable to access authenticated user data in
`useCoreCommands.ts`
- Modified `Comfy.ContactSupport` command to append user email
(`tf_anonymous_requester_email` and `tf_40029135130388`) and user ID
(`tf_42515251051412`) as URL parameters when available
- Maintained backward compatibility by only adding user parameters when
user is logged in
- Preserved existing `tf_42243568391700` parameter for distribution type
(oss/ccloud)

## Review Focus

- Verify that the URL parameters are correctly appended only when user
is authenticated
- Confirm that non-authenticated users still get the base support URL
with just the distribution type parameter
- Check that both Firebase auth and API key auth users have their
information properly included

Example URLs generated when you press on help locally (it will change
automatically to ccloud on Cloud):
- **Logged out**:
`https://support.comfy.org/hc/en-us/requests/new?tf_42243568391700=oss`
- **Logged in**:
`https://support.comfy.org/hc/en-us/requests/new?tf_42243568391700=ccloud&tf_anonymous_requester_email=user@example.com&tf_40029135130388=user@example.com&tf_42515251051412=abc123xyz`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6586-feat-pre-fill-user-info-in-Zendesk-support-link-2a26d73d36508171b428c634b310f68b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-11-06 19:17:01 -07:00
Alexander Brown
8752f1b06d fix: re-add translations dropped in 6564 (#6613)
## Summary

Re-adding some strings that got dropped in the merge.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6613-fix-re-add-translations-dropped-in-6564-2a36d73d3650818ca617cb5bddd11bc7)
by [Unito](https://www.unito.io)
2025-11-06 01:02:09 -08:00
Christian Byrne
90c2c0fae0 style: update Vue node designs to use semantic tokens (#6304)
## Summary

Use semantic tokens instead of colors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6304-style-update-Vue-node-designs-to-use-semantic-tokens-2986d73d365081f69acce7793a218699)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:01:39 -08:00
Jin Yi
34155bccb1 fix: Handle vite:preloadError for graceful deployment asset updates (#6609)
## Summary
- Implement graceful handling of Vite preload errors that occur when
assets are deleted after new deployments
- Auto-reload when safe (no unsaved changes), show confirmation dialog
when user has unsaved work
- Add i18n support for user-friendly error messages

## Implementation Details
- Add `vite:preloadError` event listener in App.vue 
- Smart reload logic: check `app.vueAppReady` and
`workflowStore.activeWorkflow?.isModified`
- User confirmation dialog using existing `dialogService.confirm`
- Comprehensive i18n keys for title and message

## Background
This addresses the issue described in [Vite
documentation](https://vite.dev/guide/build.html#load-error-handling)
where users encounter import errors when hosting services delete old
assets after new deployments.

[screen-capture
(1).webm](https://github.com/user-attachments/assets/beed3b8e-6f32-4288-a560-55da391a79a1)

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6609-fix-Handle-vite-preloadError-for-graceful-deployment-asset-updates-2a36d73d365081a0b3adeac9fcd1e1dc)
by [Unito](https://www.unito.io)
2025-11-06 00:53:56 -08:00
Arjan Singh
8849d54e20 fix: use WidgetSelectDropdown for models (#6607)
## Summary

As the commit says, the model loaders were broken in cloud if you
enabled Vue Nodes (not a thing I think user does yet).

This fixes it by configuring the `WidgetSelectDropdown` to load so the
user load models like they would load a input or output asset.

## Review Focus

Probably `useAssetWidgetData` to make sure it's idomatic.

This part of
[assetsStore](https://github.com/Comfy-Org/ComfyUI_frontend/pull/6607/files#diff-18a5914c9f12c16d9c9c3a9f6d0e203a9c00598414d3d1c8637da9ca77339d83R158-R234)
as well.

## Screenshots

<img width="1196" height="1005" alt="Screenshot 2025-11-05 at 5 34
22 PM"
src="https://github.com/user-attachments/assets/804cd3c4-3370-4667-b606-bed52fcd6278"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6607-fix-use-WidgetSelectDropdown-for-models-2a36d73d36508143b185d06d736e4af9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-11-06 03:34:17 +00:00
Alexander Brown
63cb271509 devex: Add script to launch the dev server pointed at testcloud (#6605)
## Summary

No more need to edit `.env`

Just run
```sh
pnpm dev:cloud
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6605-devex-Add-script-to-launch-the-dev-server-pointed-at-testcloud-2a36d73d3650818e9cfeedba84c54ca1)
by [Unito](https://www.unito.io)
2025-11-05 16:38:46 -08:00
Alexander Brown
22a84b1c0c hotfix: Fix dragging state not clearing after leaving (#6604)
## Summary

Fixes the state persisting when dragging over a node (but not dropping)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6604-hotfix-Fix-dragging-state-not-clearing-after-leaving-2a26d73d36508118b260eb73daee8a0b)
by [Unito](https://www.unito.io)
2025-11-05 15:42:31 -08:00
Arjan Singh
35d53c2c75 feat(WidgetSelectDropdown): support mapped display names (#6602)
## Summary

Add the ability for `WidgetSelectDropdown` to leverage `getOptionLabel`
for custom display labels.

## Review Focus

Will note inline.

## Screenshots


https://github.com/user-attachments/assets/0167cc12-e23d-4b6d-8f7f-74fd97a18397

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6602-feat-WidgetSelectDropdown-support-mapped-display-names-2a26d73d365081709c56c846e3455339)
by [Unito](https://www.unito.io)
2025-11-05 13:12:59 -08:00
Comfy Org PR Bot
3c11226fdd 1.32.2 (#6603)
Patch version increment to 1.32.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6603-1-32-2-2a26d73d365081aba4a5f7bd09a45882)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-05 14:07:51 -07:00
Christian Byrne
437c3b2da0 set config via feature flags (#6590)
In cloud environment, allow all the config values to be set by the
feature flag endpoint and be updated dynamically (on 30s poll). Retain
origianl behavior for OSS. On cloud, config changes shouldn't be changed
via redeploy and the promoted image should match the staging image.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6590-set-config-via-feature-flags-2a26d73d3650819f8084eb2695c51f22)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2025-11-05 13:45:21 -07:00
Christian Byrne
549ef79e02 update minimap and canvas bg to use menu color tokens (#6589)
Update minimap and graph canvas menu (bottom right) to use menu tokens.
Change canvas BG color on default dark theme.

<img width="3840" height="2029" alt="image"
src="https://github.com/user-attachments/assets/6d168981-df27-40c0-829c-59150b8a6a12"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6589-wip-Style-graph-canvas-color-2a26d73d365081cb88c4c4bdb2b6d3a5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-05 12:16:19 -08:00
Arjan Singh
a2ef569b9c feat(ComboWidget): add ability to have mapped inputs (#6585)
## Summary

1. Add a `getOptionLabel` option to `ComboWidget` so users of it can map
of custom labels to widget values. (e.g., `"My Photo" ->
"my_photo_1235.png"`).
2. Utilize this ability in Cloud environment to map user uploaded
filenames to their corresponding input asset.
3. Copious unit tests to make sure I didn't (AFAIK) break anything
during the refactoring portion of development.
4. Bonus: Scope model browser to only show in cloud distributions until
it's released elsewhere; should prevent some undesired UI behavior if a
user accidentally enables the assetAPI.

## Review Focus

Widget code: please double check the work there.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/a94b3203-c87f-4285-b692-479996859a5a


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6585-Feat-input-mapping-2a26d73d365081faa667e49892c8d45a)
by [Unito](https://www.unito.io)
2025-11-05 11:33:00 -08:00
Johnpaul Chiwetelu
265f1257e7 Updated node tokens (#6569)
This pull request updates the design system color tokens and refactors
node and widget component styles throughout the codebase to use new,
more consistent CSS variables. The changes ensure that node and widget
components are styled using unified design tokens, improving
maintainability and theme support for both light and dark modes.

**Design System Token Updates**

* Added new component and node-related CSS variables for background,
border, foreground, and widget states in both light and dark themes in
`style.css`.
[[1]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R246-R256)
[[2]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R354-R364)
* Introduced `--color-graphite-400` and adjusted several existing color
assignments for better palette consistency.
[[1]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R76)
[[2]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0L304-R316)
* Updated semantic CSS variables to reference the new component/node
tokens for easier usage in components.
* Changed `--secondary-background-hover` to match
`--secondary-background` for improved hover consistency.

**Component Refactoring: Node and Widget Styles**

* Refactored Vue component classes and inline styles to use the new CSS
variables for node backgrounds, borders, and widget states, replacing
legacy variables like `bg-node-component-surface` and
`border-node-component-border` with `bg-component-node-background` and
`border-component-node-border`.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L11-R14)
[[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L39-R39)
[[3]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L384-R384)
[[4]](diffhunk://#diff-19537a67677431ecdc9aec43877d28814e37edf0e45b0b0b484ea08832cad299L5-R13)
* Updated widget dropdowns, select, and input components to use
`text-component-node-foreground-secondary` for icons and foregrounds,
and new background variables for buttons and inputs.
[[1]](diffhunk://#diff-489229f88dfdfd5d883a3ef7fad6effa0790a18a831d5a9d84642dfb246962a2L29-R29)
[[2]](diffhunk://#diff-489229f88dfdfd5d883a3ef7fad6effa0790a18a831d5a9d84642dfb246962a2L100-R100)
[[3]](diffhunk://#diff-661a09de2721335e118a693b25d09922ada0ccbd0a51284691ed784fbe18874eL13-R13)
[[4]](diffhunk://#diff-2856391d03b0d38db1ed922b5034a05bc32e978c51f8175057d84cf82399d986L13-R13)
[[5]](diffhunk://#diff-4ee47848821aff71b6da0a1bb7fb8976e7879d706f71ff2ab3c5b046f5ef528cL10-R10)
[[6]](diffhunk://#diff-8b7ed2ce6194a262fb1e950294699cb8722630920362143a765802b602ae5fc8L106-R113)
[[7]](diffhunk://#diff-8b7ed2ce6194a262fb1e950294699cb8722630920362143a765802b602ae5fc8L119-R123)
[[8]](diffhunk://#diff-597a77456bf4b0c2d390fc46a930f37156b2f26ca030259b6703e5d39ff6b20eL37-R53)
[[9]](diffhunk://#diff-29348fa2e5b8cec1301a99bdec241379aeefc1747cceeb0c39b7df452ca635ffL7-R7)

**Service Layer Updates**

* Updated the color palette service mapping to use the new CSS variable
names for node and widget colors, ensuring consistency across the
application.
* 



https://github.com/user-attachments/assets/d9535f9a-b459-49bf-b2fe-ed872916fa4e



These changes collectively modernize the styling approach for node and
widget components, making it easier to maintain and extend theme
support.

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-05 01:13:17 -07:00
Johnpaul Chiwetelu
fac86e35bf Drag vuenodes input (#6514)
This pull request introduces several improvements to Vue reactivity and
user experience in the graph node and widget system. The main focus is
on ensuring that changes to node and widget data reliably trigger
updates in Vue components, improving drag-and-drop support for nodes,
and enhancing widget value handling for better compatibility and
reactivity.

**Vue Reactivity Improvements:**

* In `useGraphNodeManager.ts`, node data updates now create a completely
new object and add a timestamp (`_updateTs`) to force Vue's reactivity
system to detect changes. Additionally, node data is re-set on the next
tick to guarantee component updates.
[[1]](diffhunk://#diff-f980db6f42cef913c3fe92669783b255d617e40b9ccef9a1ab9cc8e326ff1790L272-R280)
[[2]](diffhunk://#diff-f980db6f42cef913c3fe92669783b255d617e40b9ccef9a1ab9cc8e326ff1790R326-R335)
* Widget value composables (`useWidgetValue` and related helpers) now
accept either a direct value or a getter function for `modelValue`, and
always normalize it to a getter. Watches are updated to use this getter
for more reliable reactivity.
[[1]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L13-R14)
[[2]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911R49-R57)
[[3]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L82-R91)
[[4]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L100-R104)
[[5]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L117-R121)
[[6]](diffhunk://#diff-92dc3c8b09ab57105e400e115196aae645214f305685044f62edc3338afa0911L140-R144)
[[7]](diffhunk://#diff-0c43cefa9fb524ae86541c7ca851e97a22b3fd01f95795c83273c977be77468fL47-R47)
* In `useImageUploadWidget.ts`, widget value updates now use a new
array/object to ensure Vue detects the change, especially for batch
uploads.

**Drag-and-Drop Support for Nodes:**

* The `LGraphNode.vue` component adds drag-and-drop event handlers
(`dragover`, `dragleave`, `drop`) and visual feedback (`isDraggingOver`
state and highlight ring) for improved user experience when dragging
files onto nodes. Node callbacks (`onDragOver`, `onDragDrop`) are used
for custom validation and handling.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2L26-R27)
[[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R47-R49)
[[3]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R482-R521)

**Widget and Audio Upload Handling:**

* In `uploadAudio.ts`, after uploading an audio file, the widget's
callback is manually triggered to ensure Vue nodes update. There is also
a commented-out call to mark the canvas as dirty for potential future
refresh logic.
[[1]](diffhunk://#diff-796b36f2cafb906a5e95b5750ca5ddc1bf57a304d4a022e0bdaee04b4ee5bbc4R61-R65)
[[2]](diffhunk://#diff-796b36f2cafb906a5e95b5750ca5ddc1bf57a304d4a022e0bdaee04b4ee5bbc4R190-R191)

These changes collectively improve the reliability and responsiveness of
UI updates in the graph node system, especially in scenarios involving
external updates, drag-and-drop interactions, and batch widget value
changes.



https://github.com/user-attachments/assets/8e3194c9-196c-4e13-ad0b-a32177f2d062



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6514-Drag-vuenodes-input-29e6d73d3650817da1b7ef96b61b752d)
by [Unito](https://www.unito.io)
2025-11-05 09:11:56 +01:00
Alexander Brown
693fbbd3e4 Mainification: Bring Onboarding in from rh-test (#6564)
## Summary

Migrate the onboarding / login / sign-up / survey pieces from `rh-test`
to `main`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6564-WIP-Bring-Onboarding-in-from-rh-test-2a16d73d365081318483f993e3ca0f89)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2025-11-04 16:48:58 -08:00
Christian Byrne
47688fe363 fix minimap navigation on touch devices (#6580)
Fixes minimap navigation (dragging the viewport box on the minimap) on
touch devices.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6580-fix-minimap-navigation-on-touch-devices-2a16d73d36508195b070da2b8e4b908a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-04 15:18:30 -07:00
AustinMroz
7c2a768d83 More forgiving connections in vue (#6565)
The previous link connection code uses
[closest](https://developer.mozilla.org/en-US/docs/Web/API/Element/closest)
to find a slot. Closest only checks parents, not siblings. Since the
sought element has no children, this meant connection to a slot required
the mouse be directly over the slot.

This is changed by finding the closest (parent) widget or slot, and then
querying for the slot. For simplicity, this means introducing an
`lg-node-widget` class. As a result, connections can be made by hovering
anywhere over a valid widget.


![vue-connections_00001](https://github.com/user-attachments/assets/e556ff3f-8cbb-4198-998d-9c2aadf2c73c)


Resolves #6488

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6565-More-forgiving-connections-in-vue-2a16d73d365081e1bf46f5d54ec382d6)
by [Unito](https://www.unito.io)
2025-11-04 13:45:14 -08:00
Christian Byrne
a4fc68a9eb make subscribe-to-run button responsive (#6581)
## Summary

Change to just "Subscribe" on mobile breakpoint.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6581-make-subscribe-to-run-button-responsive-2a16d73d365081e3a776cde0290432f3)
by [Unito](https://www.unito.io)
2025-11-04 13:33:22 -08:00
173 changed files with 6728 additions and 463 deletions

View File

@@ -0,0 +1,116 @@
name: Post Release Summary Comment
description: Post or update a PR comment summarizing release links with diff, derived versions, and optional extras.
author: ComfyUI Frontend Team
inputs:
issue-number:
description: Optional PR number override (defaults to the current pull request)
default: ''
version_file:
description: Path to the JSON file containing the current version (relative to repo root)
required: true
outputs:
prev_version:
description: Previous version derived from the parent commit
value: ${{ steps.build.outputs.prev_version }}
runs:
using: composite
steps:
- name: Build comment body
id: build
shell: bash
run: |
set -euo pipefail
VERSION_FILE="${{ inputs.version_file }}"
REPO="${{ github.repository }}"
if [[ -z "$VERSION_FILE" ]]; then
echo '::error::version_file input is required' >&2
exit 1
fi
PREV_JSON=$(git show HEAD^1:"$VERSION_FILE" 2>/dev/null || true)
if [[ -z "$PREV_JSON" ]]; then
echo "::error::Unable to read $VERSION_FILE from parent commit" >&2
exit 1
fi
PREV_VERSION=$(printf '%s' "$PREV_JSON" | node -pe "const data = JSON.parse(require('fs').readFileSync(0, 'utf8')); if (!data.version) { process.exit(1); } data.version")
if [[ -z "$PREV_VERSION" ]]; then
echo "::error::Unable to determine previous version from $VERSION_FILE" >&2
exit 1
fi
NEW_VERSION=$(node -pe "const fs=require('fs');const data=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(!data.version){process.exit(1);}data.version" "$VERSION_FILE")
if [[ -z "$NEW_VERSION" ]]; then
echo "::error::Unable to determine current version from $VERSION_FILE" >&2
exit 1
fi
MARKER='release-summary'
MESSAGE='Publish jobs finished successfully:'
LINKS_VALUE=''
case "$VERSION_FILE" in
package.json)
LINKS_VALUE=$'PyPI|https://pypi.org/project/comfyui-frontend-package/{{version}}/\n''npm types|https://npm.im/@comfyorg/comfyui-frontend-types@{{version}}'
;;
apps/desktop-ui/package.json)
MARKER='desktop-release-summary'
LINKS_VALUE='npm desktop UI|https://npm.im/@comfyorg/desktop-ui@{{version}}'
;;
esac
DIFF_PREFIX='v'
DIFF_LABEL='Diff'
DIFF_URL="https://github.com/${REPO}/compare/${DIFF_PREFIX}${PREV_VERSION}...${DIFF_PREFIX}${NEW_VERSION}"
COMMENT_FILE=$(mktemp)
{
printf '<!--%s:%s%s-->\n' "$MARKER" "$DIFF_PREFIX" "$NEW_VERSION"
printf '%s\n\n' "$MESSAGE"
printf -- '- %s: [%s%s...%s%s](%s)\n' "$DIFF_LABEL" "$DIFF_PREFIX" "$PREV_VERSION" "$DIFF_PREFIX" "$NEW_VERSION" "$DIFF_URL"
while IFS= read -r RAW_LINE; do
LINE=$(printf '%s' "$RAW_LINE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$LINE" ]] && continue
if [[ "$LINE" != *"|"* ]]; then
echo "::warning::Skipping malformed link entry: $LINE" >&2
continue
fi
LABEL=${LINE%%|*}
URL_TEMPLATE=${LINE#*|}
URL=${URL_TEMPLATE//\{\{version\}\}/$NEW_VERSION}
URL=${URL//\{\{prev_version\}\}/$PREV_VERSION}
printf -- '- %s: %s\n' "$LABEL" "$URL"
done <<< "$LINKS_VALUE"
printf '\n'
} > "$COMMENT_FILE"
{
echo "body<<'EOF'"
cat "$COMMENT_FILE"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
echo "prev_version=$PREV_VERSION" >> "$GITHUB_OUTPUT"
echo "marker_search=<!--$MARKER:" >> "$GITHUB_OUTPUT"
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- name: Find existing comment
id: find
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: ${{ steps.build.outputs.marker_search }}
- name: Post or update comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-id: ${{ steps.find.outputs.comment-id }}
body: ${{ steps.build.outputs.body }}
edit-mode: replace

View File

@@ -1,9 +1,10 @@
---
name: Publish Desktop UI on PR Merge
on:
pull_request:
types: [ closed ]
branches: [ main, core/* ]
types: ['closed']
branches: [main, core/*]
paths:
- 'apps/desktop-ui/package.json'
@@ -57,3 +58,26 @@ jobs:
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
comment_desktop_publish:
name: Comment Desktop Publish Summary
needs:
- resolve
- publish
if: success()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2
- name: Post desktop release summary comment
uses: ./.github/actions/comment-release-links
with:
issue-number: ${{ github.event.pull_request.number }}
version_file: apps/desktop-ui/package.json

View File

@@ -1,9 +1,10 @@
---
name: Release Draft Create
on:
pull_request:
types: [ closed ]
branches: [ main, core/* ]
types: ['closed']
branches: [main, core/*]
paths:
- 'package.json'
@@ -30,7 +31,9 @@ jobs:
- name: Get current version
id: current_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Check if prerelease
id: check_prerelease
run: |
@@ -71,7 +74,8 @@ jobs:
name: dist-files
- name: Create release
id: create_release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
uses: >-
softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -79,9 +83,14 @@ jobs:
dist.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: ${{ github.event.pull_request.base.ref == 'main' && needs.build.outputs.is_prerelease == 'false' }}
draft: ${{ github.event.pull_request.base.ref != 'main' || needs.build.outputs.is_prerelease == 'true' }}
prerelease: ${{ needs.build.outputs.is_prerelease == 'true' }}
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true
publish_pypi:
@@ -110,7 +119,8 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: >-
pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist
@@ -122,3 +132,28 @@ jobs:
version: ${{ needs.build.outputs.version }}
ref: ${{ github.event.pull_request.merge_commit_sha }}
secrets: inherit
comment_release_summary:
name: Comment Release Summary
needs:
- draft_release
- publish_pypi
- publish_types
if: success()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2
- name: Post release summary comment
uses: ./.github/actions/comment-release-links
with:
issue-number: ${{ github.event.pull_request.number }}
version_file: package.json

View File

@@ -74,8 +74,15 @@ const config: StorybookConfig = {
'@': process.cwd() + '/src'
}
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
treeshake: false,
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

@@ -75,8 +75,15 @@ const config: StorybookConfig = {
'@frontend-locales': process.cwd() + '/../../src/locales'
}
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
treeshake: false,
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

@@ -0,0 +1,206 @@
<template>
<Select
:id="dropdownId"
v-model="selectedLocale"
:options="localeOptions"
option-label="label"
option-value="value"
:disabled="isSwitching"
:pt="dropdownPt"
:size="props.size"
class="language-selector"
@change="onLocaleChange"
>
<template #value="{ value }">
<span :class="valueClass">
<i class="pi pi-language" :class="iconClass" />
<span>{{ displayLabel(value as SupportedLocale) }}</span>
</span>
</template>
<template #option="{ option }">
<span :class="optionClass">
<i class="pi pi-language" :class="iconClass" />
<span class="leading-none">{{ option.label }}</span>
</span>
</template>
</Select>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import type { SelectChangeEvent } from 'primevue/select'
import { computed, ref, watch } from 'vue'
import { i18n, loadLocale, st } from '@/i18n'
type VariantKey = 'dark' | 'light'
type SizeKey = 'small' | 'large'
const props = withDefaults(
defineProps<{
variant?: VariantKey
size?: SizeKey
}>(),
{
variant: 'dark',
size: 'small'
}
)
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
const LOCALES = [
['en', 'English'],
['zh', '中文'],
['zh-TW', '繁體中文'],
['ru', 'Русский'],
['ja', '日本語'],
['ko', '한국어'],
['fr', 'Français'],
['es', 'Español'],
['ar', 'عربي'],
['tr', 'Türkçe']
] as const satisfies ReadonlyArray<[string, string]>
type SupportedLocale = (typeof LOCALES)[number][0]
const SIZE_PRESETS = {
large: {
wrapper: 'px-3 py-1 min-w-[7rem]',
gap: 'gap-2',
valueText: 'text-xs',
optionText: 'text-sm',
icon: 'text-sm'
},
small: {
wrapper: 'px-2 py-0.5 min-w-[5rem]',
gap: 'gap-1',
valueText: 'text-[0.65rem]',
optionText: 'text-xs',
icon: 'text-xs'
}
} as const satisfies Record<SizeKey, Record<string, string>>
const VARIANT_PRESETS = {
light: {
root: 'bg-white/80 border border-neutral-200 text-neutral-700 rounded-full shadow-sm backdrop-blur hover:border-neutral-400 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-white',
trigger: 'text-neutral-500 hover:text-neutral-700',
item: 'text-neutral-700 bg-transparent hover:bg-neutral-100 focus-visible:outline-none',
valueText: 'text-neutral-600',
optionText: 'text-neutral-600',
icon: 'text-neutral-500'
},
dark: {
root: 'bg-neutral-900/70 border border-neutral-700 text-neutral-200 rounded-full shadow-sm backdrop-blur hover:border-neutral-500 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900',
trigger: 'text-neutral-400 hover:text-neutral-200',
item: 'text-neutral-200 bg-transparent hover:bg-neutral-800/80 focus-visible:outline-none',
valueText: 'text-neutral-100',
optionText: 'text-neutral-100',
icon: 'text-neutral-300'
}
} as const satisfies Record<VariantKey, Record<string, string>>
const selectedLocale = ref<string>(i18n.global.locale.value)
const isSwitching = ref(false)
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
const variantPreset = computed(
() => VARIANT_PRESETS[props.variant as VariantKey]
)
const dropdownPt = computed(() => ({
root: {
class: `${variantPreset.value.root} ${sizePreset.value.wrapper}`
},
trigger: {
class: variantPreset.value.trigger
},
item: {
class: `${variantPreset.value.item} ${sizePreset.value.optionText}`
}
}))
const valueClass = computed(() =>
[
'flex items-center font-medium uppercase tracking-wide leading-tight',
sizePreset.value.gap,
sizePreset.value.valueText,
variantPreset.value.valueText
].join(' ')
)
const optionClass = computed(() =>
[
'flex items-center leading-tight',
sizePreset.value.gap,
variantPreset.value.optionText,
sizePreset.value.optionText
].join(' ')
)
const iconClass = computed(() =>
[sizePreset.value.icon, variantPreset.value.icon].join(' ')
)
const localeOptions = computed(() =>
LOCALES.map(([value, fallback]) => ({
value,
label: st(`settings.Comfy_Locale.options.${value}`, fallback)
}))
)
const labelLookup = computed(() =>
localeOptions.value.reduce<Record<string, string>>((acc, option) => {
acc[option.value] = option.label
return acc
}, {})
)
function displayLabel(locale?: SupportedLocale) {
if (!locale) {
return st('settings.Comfy_Locale.name', 'Language')
}
return labelLookup.value[locale] ?? locale
}
watch(
() => i18n.global.locale.value,
(newLocale) => {
if (newLocale !== selectedLocale.value) {
selectedLocale.value = newLocale
}
}
)
async function onLocaleChange(event: SelectChangeEvent) {
const nextLocale = event.value as SupportedLocale | undefined
if (!nextLocale || nextLocale === i18n.global.locale.value) {
return
}
isSwitching.value = true
try {
await loadLocale(nextLocale)
i18n.global.locale.value = nextLocale
} catch (error) {
console.error(`Failed to change locale to "${nextLocale}"`, error)
selectedLocale.value = i18n.global.locale.value
} finally {
isSwitching.value = false
}
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-dropdown-panel .p-dropdown-item) {
@apply transition-colors;
}
:deep(.p-dropdown) {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<BaseViewTemplate dark>
<BaseViewTemplate dark hide-language-selector>
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"

View File

@@ -1,7 +1,7 @@
<template>
<BaseViewTemplate dark>
<div class="flex items-center justify-center min-h-screen">
<div class="grid grid-rows-2 gap-8">
<div class="grid gap-8">
<!-- Top container: Logo -->
<div class="flex items-end justify-center">
<img

View File

@@ -1,12 +1,15 @@
<template>
<div
class="font-sans w-screen h-screen flex flex-col"
class="font-sans w-screen h-screen flex flex-col relative"
:class="[
dark
? 'text-neutral-300 bg-neutral-900 dark-theme'
: 'text-neutral-900 bg-neutral-300'
]"
>
<div v-if="showLanguageSelector" class="absolute top-6 right-6 z-10">
<LanguageSelector :variant="variant" />
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow()"
@@ -20,14 +23,20 @@
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import LanguageSelector from '@/components/common/LanguageSelector.vue'
import { electronAPI, isElectron, isNativeWindow } from '../../utils/envUtil'
const { dark = false } = defineProps<{
const { dark = false, hideLanguageSelector = false } = defineProps<{
dark?: boolean
hideLanguageSelector?: boolean
}>()
const variant = computed(() => (dark ? 'dark' : 'light'))
const showLanguageSelector = computed(() => !hideLanguageSelector)
const darkTheme = {
color: 'rgba(0, 0, 0, 0)',
symbolColor: '#d4d4d4'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 77 KiB

14
global.d.ts vendored
View File

@@ -8,7 +8,21 @@ declare const __USE_PROD_CONFIG__: boolean
interface Window {
__CONFIG__: {
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: {
apiKey: string
authDomain: string
databaseURL?: string
projectId: string
storageBucket: string
messagingSenderId: string
appId: string
measurementId?: string
}
server_health_alert?: {
message: string
tooltip?: string

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.1",
"version": "1.32.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -16,6 +16,7 @@
"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/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
"dev": "nx serve",

View File

@@ -73,6 +73,7 @@
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-graphite-400: #9C9EAB;
--color-gold-400: #fcbf64;
--color-gold-500: #fdab34;
@@ -227,7 +228,7 @@
--brand-yellow: var(--color-electric-400);
--brand-blue: var(--color-sapphire-700);
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-hover: var(--color-smoke-200);
--secondary-background-selected: var(--color-smoke-600);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
@@ -242,6 +243,17 @@
--muted-background: var(--color-smoke-700);
--accent-background: var(--color-smoke-800);
/* Component/Node tokens from design system light */
--component-node-background: var(--color-white);
--component-node-border: var(--color-border-default);
--component-node-foreground: var(--base-foreground);
--component-node-foreground-secondary: var(--color-muted-foreground);
--component-node-widget-background: var(--secondary-background);
--component-node-widget-background-hovered: var(--secondary-background-hover);
--component-node-widget-background-selected: var(--secondary-background-selected);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-ash-500);
/* Default UI element color palette variables */
--palette-contrast-mix-color: #fff;
--palette-interface-panel-surface: var(--comfy-menu-bg);
@@ -301,7 +313,7 @@
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-800);
--node-component-surface: var(--color-charcoal-600);
--node-component-tooltip: var(--color-white);
--node-component-tooltip-border: var(--color-slate-300);
--node-component-tooltip-surface: var(--color-charcoal-800);
@@ -339,6 +351,17 @@
--border-subtle: var(--color-charcoal-300);
--muted-background: var(--color-charcoal-100);
--accent-background: var(--color-charcoal-100);
/* Component/Node tokens from design dark system */
--component-node-background: var(--color-charcoal-600);
--component-node-border: var(--color-charcoal-100);
--component-node-foreground: var(--base-foreground);
--component-node-foreground-secondary: var(--color-muted-foreground);
--component-node-widget-background: var(--secondary-background-hover);
--component-node-widget-background-hovered: var(--secondary-background-selected);
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
--component-node-widget-background-highlighted: var(--color-graphite-400);
}
@theme inline {
@@ -361,6 +384,14 @@
--interface-menu-keybind-surface-default
);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
--color-interface-panel-selected-surface: var(
--interface-panel-selected-surface
);
--color-interface-button-hover-surface: var(
--interface-button-hover-surface
);
--color-comfy-menu-bg: var(--comfy-menu-bg);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);
@@ -406,6 +437,17 @@
--color-text-primary: var(--text-primary);
--color-input-surface: var(--input-surface);
/* Component/Node design tokens */
--color-component-node-background: var(--component-node-background);
--color-component-node-border: var(--component-node-border);
--color-component-node-foreground: var(--component-node-foreground);
--color-component-node-foreground-secondary: var(--component-node-foreground-secondary);
--color-component-node-widget-background: var(--component-node-widget-background);
--color-component-node-widget-background-hovered: var(--component-node-widget-background-hovered);
--color-component-node-widget-background-selected: var(--component-node-widget-background-selected);
--color-component-node-widget-background-disabled: var(--component-node-widget-background-disabled);
--color-component-node-widget-background-highlighted: var(--component-node-widget-background-highlighted);
/* Semantic tokens */
--color-base-foreground: var(--base-foreground);
--color-muted-foreground: var(--muted-foreground);

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -16,6 +16,10 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { t } from '@/i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -23,6 +27,8 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -48,6 +54,26 @@ onMounted(() => {
document.addEventListener('contextmenu', showContextMenu)
}
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
window.addEventListener('vite:preloadError', async (_event) => {
// Auto-reload if app is not ready or there are no unsaved changes
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
window.location.reload()
} else {
// Show confirmation dialog if there are unsaved changes
await dialogService
.confirm({
title: t('g.vitePreloadErrorTitle'),
message: t('g.vitePreloadErrorMessage')
})
.then((confirmed) => {
if (confirmed) {
window.location.reload()
}
})
}
})
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()

View File

@@ -22,7 +22,7 @@
},
"litegraph_base": {
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
"CLEAR_BACKGROUND_COLOR": "#222",
"CLEAR_BACKGROUND_COLOR": "#141414",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
@@ -52,7 +52,7 @@
"comfy_base": {
"fg-color": "#fff",
"bg-color": "#202020",
"comfy-menu-bg": "#11141a",
"comfy-menu-bg": "#171718",
"comfy-menu-secondary-bg": "#303030",
"comfy-input-bg": "#222",
"input-text": "#ddd",

View File

@@ -1,12 +1,13 @@
<template>
<div v-if="!workspaceStore.focusMode" class="ml-2 flex pt-1">
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div
class="actionbar-container pointer-events-auto mx-1 flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
@@ -24,6 +25,7 @@ import { onMounted, ref } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'

View File

@@ -1,6 +1,6 @@
<template>
<Avatar
class="bg-gray-200 dark-theme:bg-[var(--interface-panel-selected-surface)]"
class="bg-interface-panel-selected-surface"
:image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }"

View File

@@ -96,7 +96,7 @@
<small class="text-center text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
:href="`${comfyPlatformBaseUrl}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
>
@@ -145,11 +145,15 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'
@@ -168,6 +172,13 @@ const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
const comfyPlatformBaseUrl = computed(() =>
configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
getComfyPlatformBaseUrl()
)
)
const toggleState = () => {
isSignIn.value = !isSignIn.value

View File

@@ -9,7 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
@@ -111,7 +111,7 @@ describe('ApiKeyForm', () => {
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
`${COMFY_PLATFORM_BASE_URL}/login`
`${getComfyPlatformBaseUrl()}/login`
)
})
})

View File

@@ -48,7 +48,7 @@
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
:href="`${comfyPlatformBaseUrl}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
>
@@ -88,7 +88,11 @@ import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -96,6 +100,13 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const comfyPlatformBaseUrl = computed(() =>
configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
getComfyPlatformBaseUrl()
)
)
const { t } = useI18n()

View File

@@ -2,14 +2,14 @@
<Button
ref="buttonRef"
severity="secondary"
class="group h-8 rounded-none! bg-interface-panel-surface p-0 transition-none! hover:rounded-lg! hover:bg-button-hover-surface!"
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
:style="buttonStyles"
@click="toggle"
>
<template #default>
<div class="flex items-center gap-1 pr-0.5">
<div
class="rounded-lg bg-button-active-surface p-2 group-hover:bg-button-hover-surface"
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
>
<i :class="currentModeIcon" class="block h-4 w-4" />
</div>
@@ -114,7 +114,7 @@ const popoverPt = computed(() => ({
content: {
class: [
'mb-2 text-text-primary',
'shadow-lg border border-node-border',
'shadow-lg border border-interface-stroke',
'bg-nav-background',
'rounded-lg',
'p-2 px-3',

View File

@@ -10,7 +10,7 @@
></div>
<ButtonGroup
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-[var(--interface-stroke)] bg-interface-panel-surface p-2"
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-interface-stroke bg-comfy-menu-bg p-2"
:style="{
...stringifiedMinimapStyles.buttonGroupStyles
}"
@@ -28,7 +28,7 @@
icon="pi pi-expand"
:aria-label="fitViewTooltip"
:style="stringifiedMinimapStyles.buttonStyles"
class="h-8 w-8 bg-interface-panel-surface p-0 hover:bg-button-hover-surface!"
class="h-8 w-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
>
<template #icon>
@@ -166,18 +166,18 @@ const minimapCommandText = computed(() =>
// Computed properties for button classes and states
const zoomButtonClass = computed(() => [
'bg-interface-panel-surface',
isModalVisible.value ? 'not-active:bg-button-active-surface!' : '',
'hover:bg-button-hover-surface!',
'bg-comfy-menu-bg',
isModalVisible.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
'hover:bg-interface-button-hover-surface!',
'p-0',
'h-8',
'w-15'
])
const minimapButtonClass = computed(() => ({
'bg-interface-panel-surface': true,
'hover:bg-button-hover-surface!': true,
'not-active:bg-button-active-surface!': settingStore.get(
'bg-comfy-menu-bg': true,
'hover:bg-interface-button-hover-surface!': true,
'not-active:bg-interface-panel-selected-surface!': settingStore.get(
'Comfy.Minimap.Visible'
),
'p-0': true,
@@ -209,9 +209,9 @@ const linkVisibilityAriaLabel = computed(() =>
: t('graphCanvasMenu.hideLinks')
)
const linkVisibleClass = computed(() => [
'bg-interface-panel-surface',
linkHidden.value ? 'not-active:bg-button-active-surface!' : '',
'hover:bg-button-hover-surface!',
'bg-comfy-menu-bg',
linkHidden.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
'hover:bg-interface-button-hover-surface!',
'p-0',
'w-8',
'h-8'

View File

@@ -4,7 +4,7 @@
class="absolute right-0 bottom-[62px] z-1300 flex w-[250px] justify-center border-0! bg-inherit!"
>
<div
class="w-4/5 rounded-lg border border-node-border bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
class="w-4/5 rounded-lg border border-interface-stroke bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
:style="filteredMinimapStyles"
@click.stop
>

View File

@@ -103,7 +103,7 @@ if (isComponentWidget(props.widget)) {
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
const {
// configs
// config
sceneConfig,
modelConfig,
cameraConfig,

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex h-full shrink-0 items-center gap-1">
<Button
v-for="(button, index) in actionBarButtonStore.buttons"
:key="index"
v-tooltip.bottom="button.tooltip"
:label="button.label"
:aria-label="button.tooltip || button.label"
:class="button.class"
text
rounded
severity="secondary"
class="h-7"
@click="button.onClick"
>
<template #icon>
<i :class="button.icon" />
</template>
</Button>
</div>
</template>
<script lang="ts" setup>
import Button from 'primevue/button'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
const actionBarButtonStore = useActionBarButtonStore()
</script>

View File

@@ -10,7 +10,7 @@
@click="popover?.toggle($event)"
>
<div
class="flex items-center gap-1 rounded-full hover:bg-[var(--interface-button-hover-surface)]"
class="flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface"
>
<UserAvatar :photo-url="photoURL" />

View File

@@ -4,7 +4,7 @@
outlined
rounded
severity="secondary"
class="size-8 border-black/50 bg-transparent text-black hover:bg-[var(--interface-panel-hover-surface)] dark-theme:border-white/50 dark-theme:text-white"
class="size-8 border-black/50 bg-transparent text-black hover:bg-interface-panel-hover-surface dark-theme:border-white/50 dark-theme:text-white"
@click="handleSignIn()"
@mouseenter="showPopover"
@mouseleave="hidePopover"

View File

@@ -1,7 +1,6 @@
import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
@@ -61,8 +60,7 @@ export const useFirebaseAuthActions = () => {
if (isCloud) {
try {
const router = useRouter()
await router.push({ name: 'cloud-login' })
window.location.href = '/cloud/login'
} catch (error) {
// needed for local development until we bring in cloud login pages.
window.location.reload()

View File

@@ -269,10 +269,13 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
)
vueNodeData.set(nodeId, {
// Create a completely new object to ensure Vue reactivity triggers
const updatedData = {
...currentData,
widgets: updatedWidgets
})
}
vueNodeData.set(nodeId, updatedData)
} catch (error) {
// Ignore widget update errors to prevent cascade failures
}

View File

@@ -2,16 +2,17 @@
* Composable for managing widget value synchronization between Vue and LiteGraph
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
*/
import { ref, watch } from 'vue'
import { computed, toValue, ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { MaybeRefOrGetter } from '@vueuse/core'
interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
/** The widget configuration from LiteGraph */
widget: SimplifiedWidget<T>
/** The current value from parent component */
modelValue: T
/** The current value from parent component (can be a value or a getter function) */
modelValue: MaybeRefOrGetter<T>
/** Default value if modelValue is null/undefined */
defaultValue: T
/** Emit function from component setup */
@@ -46,8 +47,21 @@ export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
emit,
transform
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
// Local value for immediate UI updates
const localValue = ref<T>(modelValue ?? defaultValue)
// Ref for immediate UI feedback before value flows back through modelValue
const newProcessedValue = ref<T | null>(null)
// Computed that prefers the immediately processed value, then falls back to modelValue
const localValue = computed<T>(
() => newProcessedValue.value ?? toValue(modelValue) ?? defaultValue
)
// Clear newProcessedValue when modelValue updates (allowing external changes to flow through)
watch(
() => toValue(modelValue),
() => {
newProcessedValue.value = null
}
)
// Handle user changes
const onChange = (newValue: U) => {
@@ -71,21 +85,13 @@ export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
}
}
// 1. Update local state for immediate UI feedback
localValue.value = processedValue
// Set for immediate UI feedback
newProcessedValue.value = processedValue
// 2. Emit to parent component
// Emit to parent component
emit('update:modelValue', processedValue)
}
// Watch for external updates from LiteGraph
watch(
() => modelValue,
(newValue) => {
localValue.value = newValue ?? defaultValue
}
)
return {
localValue: localValue as Ref<T>,
onChange
@@ -97,7 +103,7 @@ export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
*/
export function useStringWidgetValue(
widget: SimplifiedWidget<string>,
modelValue: string,
modelValue: string | (() => string),
emit: (event: 'update:modelValue', value: string) => void
) {
return useWidgetValue({
@@ -114,7 +120,7 @@ export function useStringWidgetValue(
*/
export function useNumberWidgetValue(
widget: SimplifiedWidget<number>,
modelValue: number,
modelValue: number | (() => number),
emit: (event: 'update:modelValue', value: number) => void
) {
return useWidgetValue({
@@ -137,7 +143,7 @@ export function useNumberWidgetValue(
*/
export function useBooleanWidgetValue(
widget: SimplifiedWidget<boolean>,
modelValue: boolean,
modelValue: boolean | (() => boolean),
emit: (event: 'update:modelValue', value: boolean) => void
) {
return useWidgetValue({

View File

@@ -168,6 +168,7 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
const hasWidget = node.widgets?.some((w) => w.name === VIDEO_WIDGET_NAME)
if (!hasWidget) {
const widget = node.addDOMWidget(VIDEO_WIDGET_NAME, 'video', container, {
canvasOnly: true,
hideOnZoom: false
})
widget.serialize = false

View File

@@ -5,6 +5,7 @@ import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const DEFAULT_TITLE = 'ComfyUI'
const TITLE_SUFFIX = ' - ComfyUI'
@@ -13,6 +14,7 @@ export const useBrowserTabTitle = () => {
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const workspaceStore = useWorkspaceStore()
const executionText = computed(() =>
executionStore.isIdle
@@ -24,11 +26,27 @@ export const useBrowserTabTitle = () => {
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const isAutoSaveEnabled = computed(
() => settingStore.get('Comfy.Workflow.AutoSave') === 'after delay'
)
const isActiveWorkflowModified = computed(
() => !!workflowStore.activeWorkflow?.isModified
)
const isActiveWorkflowPersisted = computed(
() => !!workflowStore.activeWorkflow?.isPersisted
)
const shouldShowUnsavedIndicator = computed(() => {
if (workspaceStore.shiftDown) return false
if (isAutoSaveEnabled.value) return false
if (!isActiveWorkflowPersisted.value) return true
if (isActiveWorkflowModified.value) return true
return false
})
const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.isModified ||
!workflowStore.activeWorkflow?.isPersisted
? ' *'
: ''
shouldShowUnsavedIndicator.value ? ' *' : ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.filename

View File

@@ -1,3 +1,4 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
@@ -20,7 +21,7 @@ import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBro
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSettingStore } from '@/platform/settings/settingStore'
import { SUPPORT_URL } from '@/platform/support/config'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -840,7 +841,12 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
window.open(SUPPORT_URL, '_blank')
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank')
}
},
{

View File

@@ -1,7 +1,43 @@
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__
? 'https://api.comfy.org'
: 'https://stagingapi.comfy.org'
import { isCloud } from '@/platform/distribution/types'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
export const COMFY_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? 'https://platform.comfy.org'
: 'https://stagingplatform.comfy.org'
const PROD_API_BASE_URL = 'https://api.comfy.org'
const STAGING_API_BASE_URL = 'https://stagingapi.comfy.org'
const PROD_PLATFORM_BASE_URL = 'https://platform.comfy.org'
const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org'
const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__
? PROD_API_BASE_URL
: STAGING_API_BASE_URL
const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? PROD_PLATFORM_BASE_URL
: STAGING_PLATFORM_BASE_URL
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
BUILD_TIME_API_BASE_URL
)
}
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
BUILD_TIME_PLATFORM_BASE_URL
)
}

View File

@@ -1,5 +1,8 @@
import type { FirebaseOptions } from 'firebase/app'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEV_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
authDomain: 'dreamboothy-dev.firebaseapp.com',
@@ -22,7 +25,18 @@ const PROD_CONFIG: FirebaseOptions = {
measurementId: 'G-3ZBD3MBTG4'
}
// To test with prod config while using dev server, set USE_PROD_CONFIG=true in .env
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_CONFIG__
? PROD_CONFIG
: DEV_CONFIG
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
/**
* Returns the Firebase configuration for the current environment.
* - Cloud builds use runtime configuration delivered via feature flags
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
}

View File

@@ -0,0 +1,23 @@
import { t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
// Zendesk feedback URL - update this with the actual URL
const ZENDESK_FEEDBACK_URL =
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=43066738713236'
const buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-circle-question-mark]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(ZENDESK_FEEDBACK_URL, '_blank', 'noopener,noreferrer')
}
}
]
useExtensionService().registerExtension({
name: 'Comfy.Cloud.FeedbackButton',
actionBarButtons: buttons
})

View File

@@ -29,6 +29,7 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./cloudFeedbackTopbarButton')
if (window.__CONFIG__?.subscription_required) {
await import('./cloudSubscription')

View File

@@ -4,6 +4,7 @@ import { app } from '../../scripts/app'
const saveNodeTypes = new Set([
'SaveImage',
'SaveVideo',
'SaveAnimatedWEBP',
'SaveWEBM',
'SaveAudio',

View File

@@ -58,6 +58,9 @@ async function uploadFile(
getResourceURL(...splitFilePath(path))
)
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
}
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)

View File

@@ -18,7 +18,7 @@ import { ComfyWidgets, addValueControlWidgets } from '@/scripts/widgets'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/utils/typeGuardUtil'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {

View File

@@ -29,6 +29,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
canvasOnly?: boolean
values?: TValues
/** Optional function to format values for display (e.g., hash → human-readable name) */
getOptionLabel?: (value?: string | null) => string
callback?: IWidget['callback']
}

View File

@@ -34,6 +34,18 @@ export class ComboWidget
override get _displayValue() {
if (this.computedDisabled) return ''
if (this.options.getOptionLabel) {
try {
return this.options.getOptionLabel(
this.value ? String(this.value) : null
)
} catch (e) {
console.error('Failed to map value:', e)
return this.value ? String(this.value) : ''
}
}
const { values: rawValues } = this.options
if (rawValues) {
const values = typeof rawValues === 'function' ? rawValues() : rawValues
@@ -131,7 +143,31 @@ export class ComboWidget
const values = this.getValues(node)
const values_list = toArray(values)
// Handle center click - show dropdown menu
// Use addItem to solve duplicate filename issues
if (this.options.getOptionLabel) {
const menuOptions = {
scale: Math.max(1, canvas.ds.scale),
event: e,
className: 'dark',
callback: (value: string) => {
this.setValue(value, { e, node, canvas })
}
}
const menu = new LiteGraph.ContextMenu([], menuOptions)
for (const value of values_list) {
try {
const label = this.options.getOptionLabel(String(value))
menu.addItem(label, value, menuOptions)
} catch (err) {
console.error('Failed to map value:', err)
menu.addItem(String(value), value, menuOptions)
}
}
return
}
// Show dropdown menu when user clicks on widget label
const text_values = values != values_list ? Object.values(values) : values
new LiteGraph.ContextMenu(text_values, {
scale: Math.max(1, canvas.ds.scale),

View File

@@ -1507,7 +1507,6 @@
"Video": "فيديو",
"Video API": "واجهة برمجة تطبيقات الفيديو"
},
"licensesSelected": "{count} تراخيص",
"loading": "جارٍ تحميل القوالب...",
"loadingMore": "تحميل المزيد من القوالب...",
"modelFilter": "مرشح النماذج",

View File

@@ -40,6 +40,8 @@
"comfy": "Comfy",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"vitePreloadErrorTitle": "New Version Available",
"vitePreloadErrorMessage": "A new version of the app has been released. Would you like to reload?\nIf not, some parts of the app might not work as expected.\nFeel free to decline and save your progress before reloading.",
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
@@ -180,6 +182,10 @@
"title": "Title",
"edit": "Edit",
"copy": "Copy",
"copyJobId": "Copy Job ID",
"copied": "Copied",
"jobIdCopied": "Job ID copied to clipboard",
"failedToCopyJobId": "Failed to copy job ID",
"imageUrl": "Image URL",
"clear": "Clear",
"clearAll": "Clear all",
@@ -759,6 +765,310 @@
"Partner Nodes": "Partner Nodes",
"Generation Type": "Generation Type"
},
"templateDescription": {
"Basics": {
"default": "Generate images from text prompts.",
"image2image": "Transform existing images using text prompts.",
"lora": "Generate images with LoRA models for specialized styles or subjects.",
"lora_multiple": "Generate images by combining multiple LoRA models.",
"inpaint_example": "Edit specific parts of images seamlessly.",
"inpaint_model_outpainting": "Extend images beyond their original boundaries.",
"embedding_example": "Generate images using textual inversion for consistent styles.",
"gligen_textbox_example": "Generate images with precise object placement using text boxes."
},
"Flux": {
"flux_kontext_dev_basic": "Edit image using Flux Kontext with full node visibility, perfect for learning the workflow.",
"flux_kontext_dev_grouped": "Streamlined version of Flux Kontext with grouped nodes for cleaner workspace.",
"flux_dev_checkpoint_example": "Generate images using Flux Dev fp8 quantized version. Suitable for devices with limited VRAM, requires only one model file, but image quality is slightly lower than the full version.",
"flux_schnell": "Quickly generate images with Flux Schnell fp8 quantized version. Ideal for low-end hardware, requires only 4 steps to generate images.",
"flux_dev_full_text_to_image": "Generate high-quality images with Flux Dev full version. Requires larger VRAM and multiple model files, but provides the best prompt following capability and image quality.",
"flux_schnell_full_text_to_image": "Generate images quickly with Flux Schnell full version. Uses Apache2.0 license, requires only 4 steps to generate images while maintaining good image quality.",
"flux_fill_inpaint_example": "Fill missing parts of images using Flux inpainting.",
"flux_fill_outpaint_example": "Extend images beyond boundaries using Flux outpainting.",
"flux_canny_model_example": "Generate images guided by edge detection using Flux Canny.",
"flux_depth_lora_example": "Generate images guided by depth information using Flux LoRA.",
"flux_redux_model_example": "Generate images by transferring style from reference images using Flux Redux."
},
"Image": {
"image_omnigen2_t2i": "Generate high-quality images from text prompts using OmniGen2's unified 7B multimodal model with dual-path architecture.",
"image_omnigen2_image_edit": "Edit images with natural language instructions using OmniGen2's advanced image editing capabilities and text rendering support.",
"image_cosmos_predict2_2B_t2i": "Generate images with Cosmos-Predict2 2B T2I, delivering physically accurate, high-fidelity, and detail-rich image generation.",
"image_chroma_text_to_image": "Chroma is modified from flux and has some changes in the architecture.",
"hidream_i1_dev": "Generate images with HiDream I1 Dev - Balanced version with 28 inference steps, suitable for medium-range hardware.",
"hidream_i1_fast": "Generate images quickly with HiDream I1 Fast - Lightweight version with 16 inference steps, ideal for rapid previews on lower-end hardware.",
"hidream_i1_full": "Generate images with HiDream I1 Full - Complete version with 50 inference steps for highest quality output.",
"hidream_e1_full": "Edit images with HiDream E1 - Professional natural language image editing model.",
"sd3_5_simple_example": "Generate images using SD 3.5.",
"sd3_5_large_canny_controlnet_example": "Generate images guided by edge detection using SD 3.5 Canny ControlNet.",
"sd3_5_large_depth": "Generate images guided by depth information using SD 3.5.",
"sd3_5_large_blur": "Generate images guided by blurred reference images using SD 3.5.",
"sdxl_simple_example": "Generate high-quality images using SDXL.",
"sdxl_refiner_prompt_example": "Enhance SDXL images using refiner models.",
"sdxl_revision_text_prompts": "Generate images by transferring concepts from reference images using SDXL Revision.",
"sdxl_revision_zero_positive": "Generate images using both text prompts and reference images with SDXL Revision.",
"sdxlturbo_example": "Generate images in a single step using SDXL Turbo.",
"image_lotus_depth_v1_1": "Run Lotus Depth in ComfyUI for zero-shot, efficient monocular depth estimation with high detail retention."
},
"Video": {
"video_cosmos_predict2_2B_video2world_480p_16fps": "Generate videos with Cosmos-Predict2 2B Video2World, generating physically accurate, high-fidelity, and consistent video simulations.",
"video_wan_vace_14B_t2v": "Transform text descriptions into high-quality videos. Supports both 480p and 720p with VACE-14B model.",
"video_wan_vace_14B_ref2v": "Create videos that match the style and content of a reference image. Perfect for style-consistent video generation.",
"video_wan_vace_14B_v2v": "Generate videos by controlling input videos and reference images using Wan VACE.",
"video_wan_vace_outpainting": "Generate extended videos by expanding video size using Wan VACE outpainting.",
"video_wan_vace_flf2v": "Generate smooth video transitions by defining start and end frames. Supports custom keyframe sequences.",
"video_wan_vace_inpainting": "Edit specific regions in videos while preserving surrounding content. Great for object removal or replacement.",
"video_wan2_1_fun_camera_v1_1_1_3B": "Generate dynamic videos with cinematic camera movements using Wan 2.1 Fun Camera 1.3B model.",
"video_wan2_1_fun_camera_v1_1_14B": "Generate high-quality videos with advanced camera control using the full 14B model",
"text_to_video_wan": "Generate videos from text prompts using Wan 2.1.",
"image_to_video_wan": "Generate videos from images using Wan 2.1.",
"wan2_1_fun_inp": "Generate videos from start and end frames using Wan 2.1 inpainting.",
"wan2_1_fun_control": "Generate videos guided by pose, depth, and edge controls using Wan 2.1 ControlNet.",
"wan2_1_flf2v_720_f16": "Generate videos by controlling first and last frames using Wan 2.1 FLF2V.",
"ltxv_text_to_video": "Generate videos from text prompts.",
"ltxv_image_to_video": "Generate videos from still images.",
"mochi_text_to_video_example": "Generate videos from text prompts using Mochi model.",
"hunyuan_video_text_to_video": "Generate videos from text prompts using Hunyuan model.",
"image_to_video": "Generate videos from still images.",
"txt_to_image_to_video": "Generate videos by first creating images from text prompts."
},
"Image API": {
"api_bfl_flux_1_kontext_multiple_images_input": "Input multiple images and edit them with Flux.1 Kontext.",
"api_bfl_flux_1_kontext_pro_image": "Edit images with Flux.1 Kontext pro image.",
"api_bfl_flux_1_kontext_max_image": "Edit images with Flux.1 Kontext max image.",
"api_bfl_flux_pro_t2i": "Generate images with excellent prompt following and visual quality using FLUX.1 Pro.",
"api_luma_photon_i2i": "Guide image generation using a combination of images and prompt.",
"api_luma_photon_style_ref": "Generate images by blending style references with precise control using Luma Photon.",
"api_recraft_image_gen_with_color_control": "Generate images with custom color palettes and brand-specific visuals using Recraft.",
"api_recraft_image_gen_with_style_control": "Control style with visual examples, align positioning, and fine-tune objects. Store and share styles for perfect brand consistency.",
"api_recraft_vector_gen": "Generate high-quality vector images from text prompts using Recraft's AI vector generator.",
"api_runway_text_to_image": "Generate high-quality images from text prompts using Runway's AI model.",
"api_runway_reference_to_image": "Generate new images based on reference styles and compositions with Runway's AI.",
"api_stability_ai_stable_image_ultra_t2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
"api_stability_ai_i2i": "Transform images with high-quality generation using Stability AI, perfect for professional editing and style transfer.",
"api_stability_ai_sd3_5_t2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
"api_stability_ai_sd3_5_i2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
"api_ideogram_v3_t2i": "Generate professional-quality images with excellent prompt alignment, photorealism, and text rendering using Ideogram V3.",
"api_openai_image_1_t2i": "Generate images from text prompts using OpenAI GPT Image 1 API.",
"api_openai_image_1_i2i": "Generate images from input images using OpenAI GPT Image 1 API.",
"api_openai_image_1_inpaint": "Edit images using inpainting with OpenAI GPT Image 1 API.",
"api_openai_image_1_multi_inputs": "Generate images from multiple inputs using OpenAI GPT Image 1 API.",
"api_openai_dall_e_2_t2i": "Generate images from text prompts using OpenAI Dall-E 2 API.",
"api_openai_dall_e_2_inpaint": "Edit images using inpainting with OpenAI Dall-E 2 API.",
"api_openai_dall_e_3_t2i": "Generate images from text prompts using OpenAI Dall-E 3 API."
},
"Video API": {
"api_moonvalley_text_to_video": "Generate cinematic, 1080p videos from text prompts through a model trained exclusively on licensed data.",
"api_moonvalley_image_to_video": "Generate cinematic, 1080p videos with an image through a model trained exclusively on licensed data.",
"api_kling_i2v": "Generate videos with excellent prompt adherence for actions, expressions, and camera movements using Kling.",
"api_kling_effects": "Generate dynamic videos by applying visual effects to images using Kling.",
"api_kling_flf": "Generate videos through controlling the first and last frames.",
"api_luma_i2v": "Take static images and instantly create magical high quality animations.",
"api_luma_t2v": "High-quality videos can be generated using simple prompts.",
"api_hailuo_minimax_t2v": "Generate high-quality videos directly from text prompts. Explore MiniMax's advanced AI capabilities to create diverse visual narratives with professional CGI effects and stylistic elements to bring your descriptions to life.",
"api_hailuo_minimax_i2v": "Generate refined videos from images and text with CGI integration using MiniMax.",
"api_pixverse_i2v": "Generate dynamic videos from static images with motion and effects using PixVerse.",
"api_pixverse_template_i2v": "Generate dynamic videos from static images with motion and effects using PixVerse.",
"api_pixverse_t2v": "Generate videos with accurate prompt interpretation and stunning video dynamics.",
"api_runway_gen3a_turbo_image_to_video": "Generate cinematic videos from static images using Runway Gen3a Turbo.",
"api_runway_gen4_turo_image_to_video": "Generate dynamic videos from images using Runway Gen4 Turbo.",
"api_runway_first_last_frame": "Generate smooth video transitions between two keyframes with Runway's precision.",
"api_pika_i2v": "Generate smooth animated videos from single static images using Pika AI.",
"api_pika_scene": "Generate videos that incorporate multiple input images using Pika Scenes.",
"api_veo2_i2v": "Generate videos from images using Google Veo2 API."
},
"3D API": {
"api_rodin_image_to_model": "Generate detailed 3D models from single photos using Rodin AI.",
"api_rodin_multiview_to_model": "Sculpt comprehensive 3D models using Rodin's multi-angle reconstruction.",
"api_tripo_text_to_model": "Craft 3D objects from descriptions with Tripo's text-driven modeling.",
"api_tripo_image_to_model": "Generate professional 3D assets from 2D images using Tripo engine.",
"api_tripo_multiview_to_model": "Build 3D models from multiple angles with Tripo's advanced scanner."
},
"LLM API": {
"api_openai_chat": "Engage with OpenAI's advanced language models for intelligent conversations.",
"api_google_gemini": "Experience Google's multimodal AI with Gemini's reasoning capabilities."
},
"Upscaling": {
"hiresfix_latent_workflow": "Upscale images by enhancing quality in latent space.",
"esrgan_example": "Upscale images using ESRGAN models to enhance quality.",
"hiresfix_esrgan_workflow": "Upscale images using ESRGAN models during intermediate generation steps.",
"latent_upscale_different_prompt_model": "Upscale images while changing prompts across generation passes."
},
"ControlNet": {
"controlnet_example": "Generate images guided by scribble reference images using ControlNet.",
"2_pass_pose_worship": "Generate images guided by pose references using ControlNet.",
"depth_controlnet": "Generate images guided by depth information using ControlNet.",
"depth_t2i_adapter": "Generate images guided by depth information using T2I adapter.",
"mixing_controlnets": "Generate images by combining multiple ControlNet models."
},
"Area Composition": {
"area_composition": "Generate images by controlling composition with defined areas.",
"area_composition_square_area_for_subject": "Generate images with consistent subject placement using area composition."
},
"3D": {
"3d_hunyuan3d_image_to_model": "Generate 3D models from single images using Hunyuan3D 2.0.",
"3d_hunyuan3d_multiview_to_model": "Generate 3D models from multiple views using Hunyuan3D 2.0 MV.",
"3d_hunyuan3d_multiview_to_model_turbo": "Generate 3D models from multiple views using Hunyuan3D 2.0 MV Turbo.",
"stable_zero123_example": "Generate 3D views from single images using Stable Zero123."
},
"Audio": {
"audio_stable_audio_example": "Generate audio from text prompts using Stable Audio.",
"audio_ace_step_1_t2a_instrumentals": "Generate instrumental music from text prompts using ACE-Step v1.",
"audio_ace_step_1_t2a_song": "Generate songs with vocals from text prompts using ACE-Step v1, supporting multilingual and style customization.",
"audio_ace_step_1_m2m_editing": "Edit existing songs to change style and lyrics using ACE-Step v1 M2M."
}
},
"template": {
"Basics": {
"default": "Image Generation",
"image2image": "Image to Image",
"lora": "LoRA",
"lora_multiple": "LoRA Multiple",
"inpaint_example": "Inpaint",
"inpaint_model_outpainting": "Outpaint",
"embedding_example": "Embedding",
"gligen_textbox_example": "Gligen Textbox"
},
"Flux": {
"flux_kontext_dev_basic": "Flux Kontext Dev(Basic)",
"flux_kontext_dev_grouped": "Flux Kontext Dev(Grouped)",
"flux_dev_checkpoint_example": "Flux Dev fp8",
"flux_schnell": "Flux Schnell fp8",
"flux_dev_full_text_to_image": "Flux Dev full text to image",
"flux_schnell_full_text_to_image": "Flux Schnell full text to image",
"flux_fill_inpaint_example": "Flux Inpaint",
"flux_fill_outpaint_example": "Flux Outpaint",
"flux_canny_model_example": "Flux Canny Model",
"flux_depth_lora_example": "Flux Depth LoRA",
"flux_redux_model_example": "Flux Redux Model"
},
"Image": {
"image_omnigen2_t2i": "OmniGen2 Text to Image",
"image_omnigen2_image_edit": "OmniGen2 Image Edit",
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I",
"image_chroma_text_to_image": "Chroma text to image",
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full",
"hidream_e1_full": "HiDream E1 Full",
"sd3_5_simple_example": "SD3.5 Simple",
"sd3_5_large_canny_controlnet_example": "SD3.5 Large Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Large Depth",
"sd3_5_large_blur": "SD3.5 Large Blur",
"sdxl_simple_example": "SDXL Simple",
"sdxl_refiner_prompt_example": "SDXL Refiner Prompt",
"sdxl_revision_text_prompts": "SDXL Revision Text Prompts",
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
"sdxlturbo_example": "SDXL Turbo",
"image_lotus_depth_v1_1": "Lotus Depth"
},
"Video": {
"video_cosmos_predict2_2B_video2world_480p_16fps": "Cosmos Predict2 2B Video2World 480p 16fps",
"video_wan_vace_14B_t2v": "Wan VACE Text to Video",
"video_wan_vace_14B_ref2v": "Wan VACE Reference to Video",
"video_wan_vace_14B_v2v": "Wan VACE Control Video",
"video_wan_vace_outpainting": "Wan VACE Outpainting",
"video_wan_vace_flf2v": "Wan VACE First-Last Frame",
"video_wan_vace_inpainting": "Wan VACE Inpainting",
"video_wan2_1_fun_camera_v1_1_1_3B": "Wan 2.1 Fun Camera 1.3B",
"video_wan2_1_fun_camera_v1_1_14B": "Wan 2.1 Fun Camera 14B",
"text_to_video_wan": "Wan 2.1 Text to Video",
"image_to_video_wan": "Wan 2.1 Image to Video",
"wan2_1_fun_inp": "Wan 2.1 Inpainting",
"wan2_1_fun_control": "Wan 2.1 ControlNet",
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V 720p F16",
"ltxv_text_to_video": "LTXV Text to Video",
"ltxv_image_to_video": "LTXV Image to Video",
"mochi_text_to_video_example": "Mochi Text to Video",
"hunyuan_video_text_to_video": "Hunyuan Video Text to Video",
"image_to_video": "SVD Image to Video",
"txt_to_image_to_video": "SVD Text to Image to Video"
},
"Image API": {
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext Multiple Image Input",
"api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext Pro",
"api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext Max",
"api_bfl_flux_pro_t2i": "BFL Flux[Pro]: Text to Image",
"api_luma_photon_i2i": "Luma Photon: Image to Image",
"api_luma_photon_style_ref": "Luma Photon: Style Reference",
"api_recraft_image_gen_with_color_control": "Recraft: Color Control Image Generation",
"api_recraft_image_gen_with_style_control": "Recraft: Style Control Image Generation",
"api_recraft_vector_gen": "Recraft: Vector Generation",
"api_runway_text_to_image": "Runway: Text to Image",
"api_runway_reference_to_image": "Runway: Reference to Image",
"api_stability_ai_stable_image_ultra_t2i": "Stability AI: Stable Image Ultra Text to Image",
"api_stability_ai_i2i": "Stability AI: Image to Image",
"api_stability_ai_sd3_5_t2i": "Stability AI: SD3.5 Text to Image",
"api_stability_ai_sd3_5_i2i": "Stability AI: SD3.5 Image to Image",
"api_ideogram_v3_t2i": "Ideogram V3: Text to Image",
"api_openai_image_1_t2i": "OpenAI: GPT-Image-1 Text to Image",
"api_openai_image_1_i2i": "OpenAI: GPT-Image-1 Image to Image",
"api_openai_image_1_inpaint": "OpenAI: GPT-Image-1 Inpaint",
"api_openai_image_1_multi_inputs": "OpenAI: GPT-Image-1 Multi Inputs",
"api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 Text to Image",
"api_openai_dall_e_2_inpaint": "OpenAI: Dall-E 2 Inpaint",
"api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 Text to Image"
},
"Video API": {
"api_moonvalley_text_to_video": "Moonvalley: Text to Video",
"api_moonvalley_image_to_video": "Moonvalley: Image to Video",
"api_kling_i2v": "Kling: Image to Video",
"api_kling_effects": "Kling: Video Effects",
"api_kling_flf": "Kling: FLF2V",
"api_luma_i2v": "Luma: Image to Video",
"api_luma_t2v": "Luma: Text to Video",
"api_hailuo_minimax_t2v": "MiniMax: Text to Video",
"api_hailuo_minimax_i2v": "MiniMax: Image to Video",
"api_pixverse_i2v": "PixVerse: Image to Video",
"api_pixverse_template_i2v": "PixVerse Templates: Image to Video",
"api_pixverse_t2v": "PixVerse: Text to Video",
"api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo Image to Video",
"api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo Image to Video",
"api_runway_first_last_frame": "Runway: First Last Frame to Video",
"api_pika_i2v": "Pika: Image to Video",
"api_pika_scene": "Pika Scenes: Images to Video",
"api_veo2_i2v": "Veo2: Image to Video"
},
"3D API": {
"api_rodin_image_to_model": "Rodin: Image to Model",
"api_rodin_multiview_to_model": "Rodin: Multiview to Model",
"api_tripo_text_to_model": "Tripo: Text to Model",
"api_tripo_image_to_model": "Tripo: Image to Model",
"api_tripo_multiview_to_model": "Tripo: Multiview to Model"
},
"LLM API": {
"api_openai_chat": "OpenAI: Chat",
"api_google_gemini": "Google Gemini: Chat"
},
"Upscaling": {
"hiresfix_latent_workflow": "Upscale",
"esrgan_example": "ESRGAN",
"hiresfix_esrgan_workflow": "HiresFix ESRGAN Workflow",
"latent_upscale_different_prompt_model": "Latent Upscale Different Prompt Model"
},
"ControlNet": {
"controlnet_example": "Scribble ControlNet",
"2_pass_pose_worship": "Pose ControlNet 2 Pass",
"depth_controlnet": "Depth ControlNet",
"depth_t2i_adapter": "Depth T2I Adapter",
"mixing_controlnets": "Mixing ControlNets"
},
"Area Composition": {
"area_composition": "Area Composition",
"area_composition_square_area_for_subject": "Area Composition Square Area for Subject"
},
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
"3d_hunyuan3d_multiview_to_model": "Hunyuan3D 2.0 MV",
"3d_hunyuan3d_multiview_to_model_turbo": "Hunyuan3D 2.0 MV Turbo",
"stable_zero123_example": "Stable Zero123"
},
"Audio": {
"audio_stable_audio_example": "Stable Audio",
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 Text to Instrumentals Music",
"audio_ace_step_1_t2a_song": "ACE Step v1 Text to Song",
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Editing"
}
},
"categories": "Categories",
"resetFilters": "Clear Filters",
"sorting": "Sort by",
@@ -1435,6 +1745,7 @@
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode...",
"edgeThreshold": "Edge Threshold",
"export": "Export",
"exportModel": "Export Model",
"exportingModel": "Exporting model...",
@@ -1445,7 +1756,8 @@
"normal": "Normal",
"wireframe": "Wireframe",
"original": "Original",
"depth": "Depth"
"depth": "Depth",
"lineart": "Lineart"
},
"upDirections": {
"original": "Original"
@@ -1559,7 +1871,12 @@
"confirmPasswordLabel": "Confirm Password",
"confirmPasswordPlaceholder": "Enter the same password again",
"forgotPassword": "Forgot password?",
"loginButton": "Log in",
"passwordResetInstructions": "Enter your email address and we'll send you a link to reset your password.",
"sendResetLink": "Send reset link",
"backToLogin": "Back to login",
"didntReceiveEmail": "Didn't receive an email? Contact us at",
"passwordResetError": "Failed to send password reset email. Please try again.",
"loginButton": "Sign in",
"orContinueWith": "Or continue with",
"loginWithGoogle": "Log in with Google",
"loginWithGithub": "Log in with Github",
@@ -1596,6 +1913,20 @@
"success": "Password Updated",
"successDetail": "Your password has been updated successfully"
},
"errors": {
"auth/invalid-email": "Please enter a valid email address.",
"auth/user-disabled": "This account has been disabled. Please contact support.",
"auth/user-not-found": "No account found with this email. Would you like to create a new account?",
"auth/wrong-password": "The password you entered is incorrect. Please try again.",
"auth/email-already-in-use": "An account with this email already exists. Try signing in instead.",
"auth/weak-password": "Password is too weak. Please use a stronger password with at least 6 characters.",
"auth/too-many-requests": "Too many login attempts. Please wait a moment and try again.",
"auth/operation-not-allowed": "This sign-in method is not currently supported.",
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
"auth/network-request-failed": "Network error. Please check your connection and try again.",
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
},
"deleteAccount": {
"deleteAccount": "Delete Account",
"confirmTitle": "Delete Account",
@@ -1696,7 +2027,8 @@
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
},
"subscribeToRun": "Subscribe to Run",
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"partnerNodesCredits": "Partner Nodes credits"
@@ -1777,6 +2109,128 @@
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"cloudOnboarding": {
"survey": {
"title": "Cloud Survey",
"placeholder": "Survey questions placeholder",
"steps": {
"familiarity": "How familiar are you with ComfyUI?",
"purpose": "What will you primarily use ComfyUI for?",
"industry": "What's your primary industry?",
"making": "What do you plan on making?"
},
"questions": {
"familiarity": "How familiar are you with ComfyUI?",
"purpose": "What will you primarily use ComfyUI for?",
"industry": "What's your primary industry?",
"making": "What do you plan on making?"
},
"options": {
"familiarity": {
"new": "New to ComfyUI (never used it before)",
"starting": "Just getting started (following tutorials)",
"basics": "Comfortable with basics",
"advanced": "Advanced user (custom workflows)",
"expert": "Expert (help others)"
},
"purpose": {
"personal": "Personal projects / hobby",
"community": "Community contributions (nodes, workflows, etc.)",
"client": "Client work (freelance)",
"inhouse": "My own workplace (in-house)",
"research": "Academic research"
},
"industry": {
"film_tv_animation": "Film, TV, & animation",
"gaming": "Gaming",
"marketing": "Marketing & advertising",
"architecture": "Architecture",
"product_design": "Product & graphic design",
"fine_art": "Fine art & illustration",
"software": "Software & technology",
"education": "Education",
"other": "Other",
"otherPlaceholder": "Please specify"
},
"making": {
"images": "Images",
"video": "Video & animation",
"3d": "3D assets",
"audio": "Audio / music",
"custom_nodes": "Custom nodes & workflows"
}
}
},
"forgotPassword": {
"title": "Forgot Password",
"instructions": "Enter your email address and we'll send you a link to reset your password.",
"emailLabel": "Email",
"emailPlaceholder": "Enter your email",
"sendResetLink": "Send reset link",
"backToLogin": "Back to login",
"didntReceiveEmail": "Didn't receive an email? Contact us at",
"passwordResetSent": "Password reset email sent",
"passwordResetError": "Failed to send password reset email. Please try again.",
"emailRequired": "Email is required"
},
"privateBeta": {
"title": "Cloud is currently in private beta",
"desc": "Sign in to join the waitlist. Well notify you when its your turn. Already been notified? Sign in start using Cloud."
},
"start": {
"title": "start creating in seconds",
"desc": "Zero setup required. Works on any device.",
"explain": "Generate multiple outputs at once. Share workflows with ease.",
"learnAboutButton": "Learn about Cloud",
"wantToRun": "Want to run ComfyUI locally instead?",
"download": "Download ComfyUI"
},
"checkingStatus": "Checking your account status...",
"retrying": "Retrying...",
"retry": "Try Again",
"authTimeout": {
"title": "Connection Taking Too Long",
"message": "We're having trouble connecting to ComfyUI Cloud. This could be due to a slow connection or temporary service issue.",
"restart": "Sign Out & Try Again",
"troubleshooting": "Common causes:",
"causes": [
"Corporate firewall or proxy blocking authentication services",
"VPN or network restrictions",
"Browser extensions interfering with requests",
"Regional network limitations",
"Try a different browser or network"
],
"technicalDetails": "Technical Details",
"helpText": "Need help? Contact",
"supportLink": "support"
}
},
"cloudFooter_needHelp": "Need Help?",
"cloudStart_title": "start creating in seconds",
"cloudStart_desc": "Zero setup required. Works on any device.",
"cloudStart_explain": "Generate multiple outputs at once. Share workflows with ease.",
"cloudStart_learnAboutButton": "Learn about Cloud",
"cloudStart_wantToRun": "Want to run ComfyUI locally instead?",
"cloudStart_download": "Download ComfyUI",
"cloudWaitlist_questionsText": "Questions? Contact us",
"cloudWaitlist_contactLink": "here",
"cloudSorryContactSupport_title": "Sorry, contact support",
"cloudPrivateBeta_title": "Cloud is currently in private beta",
"cloudPrivateBeta_desc": "Sign in to join the waitlist. We'll notify you when it's your turn. Already been notified? Sign in start using Cloud.",
"cloudForgotPassword_title": "Forgot Password",
"cloudForgotPassword_instructions": "Enter your email address and we'll send you a link to reset your password.",
"cloudForgotPassword_emailLabel": "Email",
"cloudForgotPassword_emailPlaceholder": "Enter your email",
"cloudForgotPassword_sendResetLink": "Send reset link",
"cloudForgotPassword_backToLogin": "Back to login",
"cloudForgotPassword_didntReceiveEmail": "Didn't receive an email?",
"cloudForgotPassword_emailRequired": "Email is required",
"cloudForgotPassword_passwordResetSent": "Password reset sent",
"cloudForgotPassword_passwordResetError": "Failed to send password reset email",
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
"cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?",
"cloudSurvey_steps_industry": "What's your primary industry?",
"cloudSurvey_steps_making": "What do you plan on making?",
"assetBrowser": {
"assets": "Assets",
"browseAssets": "Browse Assets",
@@ -1828,7 +2282,9 @@
}
},
"actionbar": {
"dockToTop": "Dock to top"
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback"
},
"desktopDialogs": {
"": {

View File

@@ -2908,6 +2908,11 @@
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"HyperTile": {
@@ -7876,6 +7881,11 @@
"name": "instructions",
"tooltip": "Instructions for the model on how to generate the response"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIChatNode": {
@@ -7888,7 +7898,7 @@
},
"persist_context": {
"name": "persist_context",
"tooltip": "Persist chat context between calls (multi-turn conversation)"
"tooltip": "This parameter is deprecated and has no effect."
},
"model": {
"name": "model",
@@ -7906,6 +7916,11 @@
"name": "advanced_options",
"tooltip": "Optional configuration for the model. Accepts inputs from the OpenAI Chat Advanced Options node."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIDalle2": {
@@ -7939,6 +7954,11 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIDalle3": {
@@ -7968,6 +7988,11 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIGPTImage1": {
@@ -8009,6 +8034,11 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIInputFiles": {
@@ -8023,6 +8053,11 @@
"name": "OPENAI_INPUT_FILES",
"tooltip": "An optional additional file(s) to batch together with the file loaded from this node. Allows chaining of input files so that a single message can include multiple input files."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIVideoSora2": {

View File

@@ -1504,7 +1504,6 @@
"Video": "Video",
"Video API": "API de Video"
},
"licensesSelected": "{count} licencias",
"loading": "Cargando plantillas...",
"loadingMore": "Cargando más plantillas...",
"modelFilter": "Filtro de modelo",

View File

@@ -1504,7 +1504,6 @@
"Video": "Vidéo",
"Video API": "API vidéo"
},
"licensesSelected": "{count} Licences",
"loading": "Chargement des modèles...",
"loadingMore": "Chargement de plus de modèles...",
"modelFilter": "Filtre de modèle",

View File

@@ -1504,7 +1504,6 @@
"Video": "ビデオ",
"Video API": "動画API"
},
"licensesSelected": "{count}件のライセンス",
"loading": "テンプレートを読み込み中...",
"loadingMore": "さらにテンプレートを読み込み中...",
"modelFilter": "モデルフィルター",

View File

@@ -1504,7 +1504,6 @@
"Video": "비디오",
"Video API": "비디오 API"
},
"licensesSelected": "{count}개 라이선스",
"loading": "템플릿 불러오는 중...",
"loadingMore": "템플릿 더 불러오는 중...",
"modelFilter": "모델 필터",

View File

@@ -1504,7 +1504,6 @@
"Video": "Видео",
"Video API": "Video API"
},
"licensesSelected": "{count} лицензий",
"loading": "Загрузка шаблонов...",
"loadingMore": "Загрузка дополнительных шаблонов...",
"modelFilter": "Фильтр моделей",

View File

@@ -1502,7 +1502,6 @@
"Video": "Video",
"Video API": "Video API"
},
"licensesSelected": "{count} Lisans",
"loading": "Şablonlar yükleniyor...",
"loadingMore": "Daha fazla şablon yükleniyor...",
"modelFilter": "Model Filtresi",

View File

@@ -1504,7 +1504,6 @@
"Video": "影片",
"Video API": "影片 API"
},
"licensesSelected": "{count} 個授權",
"loading": "正在載入範本...",
"loadingMore": "載入更多範本...",
"modelFilter": "模型篩選",

View File

@@ -1507,7 +1507,6 @@
"Video": "视频生成",
"Video API": "视频 API"
},
"licensesSelected": "已选 {count} 个许可类型",
"loading": "正在加载模板...",
"loadingMore": "正在加载更多模板...",
"modelFilter": "模型筛选",

View File

@@ -11,7 +11,7 @@ import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase'
import { getFirebaseConfig } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
@@ -40,7 +40,7 @@ const ComfyUIPreset = definePreset(Aura, {
}
})
const firebaseApp = initializeApp(FIREBASE_CONFIG)
const firebaseApp = initializeApp(getFirebaseConfig())
const app = createApp(App)
const pinia = createPinia()

View File

@@ -194,20 +194,31 @@ function createAssetService() {
/**
* Gets assets filtered by a specific tag
*
* @param tag - The tag to filter by (e.g., 'models')
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
* @param options - Pagination options
* @param options.limit - Maximum number of assets to return (default: 500)
* @param options.offset - Number of assets to skip (default: 0)
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
*/
async function getAssetsByTag(
tag: string,
includePublic: boolean = true
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0
}: { limit?: number; offset?: number } = {}
): Promise<AssetItem[]> {
const queryParams = new URLSearchParams({
include_tags: tag,
limit: DEFAULT_LIMIT.toString(),
limit: limit.toString(),
include_public: includePublic ? 'true' : 'false'
})
if (offset > 0) {
queryParams.set('offset', offset.toString())
}
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?${queryParams.toString()}`,
`assets for tag ${tag}`

View File

@@ -0,0 +1,97 @@
<template>
<div class="flex h-full items-center justify-center p-6">
<div class="max-w-[100vw] text-center lg:w-[500px]">
<h2 class="mb-3 text-xl text-text-primary">
{{ $t('cloudOnboarding.authTimeout.title') }}
</h2>
<p class="mb-5 text-muted">
{{ $t('cloudOnboarding.authTimeout.message') }}
</p>
<!-- Troubleshooting Section -->
<div
class="mb-4 rounded bg-surface-700 px-3 py-2 text-left dark-theme:bg-surface-800"
>
<h3 class="mb-2 text-sm font-semibold text-text-primary">
{{ $t('cloudOnboarding.authTimeout.troubleshooting') }}
</h3>
<ul class="space-y-1.5 text-sm text-muted">
<li
v-for="(cause, index) in $tm('cloudOnboarding.authTimeout.causes')"
:key="index"
class="flex gap-2"
>
<span></span>
<span>{{ cause }}</span>
</li>
</ul>
</div>
<!-- Technical Details (Collapsible) -->
<div v-if="errorMessage" class="mb-4 text-left">
<button
class="flex w-full items-center justify-between rounded bg-surface-600 px-4 py-2 text-sm text-muted transition-colors hover:bg-surface-500 dark-theme:bg-surface-700 dark-theme:hover:bg-surface-600"
@click="showTechnicalDetails = !showTechnicalDetails"
>
<span>{{ $t('cloudOnboarding.authTimeout.technicalDetails') }}</span>
<i
:class="[
'pi',
showTechnicalDetails ? 'pi-chevron-up' : 'pi-chevron-down'
]"
></i>
</button>
<div
v-if="showTechnicalDetails"
class="mt-2 rounded bg-surface-800 p-4 font-mono text-xs text-muted break-all dark-theme:bg-surface-900"
>
{{ errorMessage }}
</div>
</div>
<!-- Helpful Links -->
<p class="mb-5 text-center text-sm text-gray-600">
{{ $t('cloudOnboarding.authTimeout.helpText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('cloudOnboarding.authTimeout.supportLink') }}</a
>.
</p>
<div class="flex flex-col gap-3">
<Button
:label="$t('cloudOnboarding.authTimeout.restart')"
class="w-full"
@click="handleRestart"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
interface Props {
errorMessage?: string
}
defineProps<Props>()
const router = useRouter()
const { logout } = useFirebaseAuthActions()
const showTechnicalDetails = ref(false)
const handleRestart = async () => {
await logout()
await router.replace({ name: 'cloud-login' })
}
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-[100vw] p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('cloudForgotPassword_title') }}
</h1>
<p class="my-0 text-base text-muted">
{{ t('cloudForgotPassword_instructions') }}
</p>
</div>
<!-- Form -->
<form class="flex flex-col gap-6" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<label
class="mb-2 text-base font-medium opacity-80"
for="reset-email"
>
{{ t('cloudForgotPassword_emailLabel') }}
</label>
<InputText
id="reset-email"
v-model="email"
type="email"
:placeholder="t('cloudForgotPassword_emailPlaceholder')"
class="h-10"
:invalid="!!errorMessage && !email"
autocomplete="email"
required
/>
<small v-if="errorMessage" class="text-red-500">
{{ errorMessage }}
</small>
</div>
<Message v-if="successMessage" severity="success">
{{ successMessage }}
</Message>
<div class="flex flex-col gap-4">
<Button
type="submit"
:label="t('cloudForgotPassword_sendResetLink')"
:loading="loading"
:disabled="!email || loading"
class="h-10 font-medium text-white"
/>
<Button
type="button"
:label="t('cloudForgotPassword_backToLogin')"
severity="secondary"
class="h-10 bg-[#2d2e32]"
@click="navigateToLogin"
/>
</div>
</form>
<!-- Help text -->
<p class="mt-5 text-sm text-gray-600">
{{ t('cloudForgotPassword_didntReceiveEmail') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
const { t } = useI18n()
const router = useRouter()
const authActions = useFirebaseAuthActions()
const email = ref('')
const loading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const navigateToLogin = () => {
void router.push({ name: 'cloud-login' })
}
const handleSubmit = async () => {
if (!email.value) {
errorMessage.value = t('cloudForgotPassword_emailRequired')
return
}
loading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
// sendPasswordReset is already wrapped and returns a promise
await authActions.sendPasswordReset(email.value)
successMessage.value = t('cloudForgotPassword_passwordResetSent')
// Optionally redirect to login after a delay
setTimeout(() => {
navigateToLogin()
}, 3000)
} catch (error) {
console.error('Password reset error:', error)
errorMessage.value = t('cloudForgotPassword_passwordResetError')
} finally {
loading.value = false
}
}
</script>
<style scoped>
:deep(.p-inputtext) {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mt-6 mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('auth.login.title') }}
</h1>
<p class="my-0 text-base">
<span class="text-muted">{{ t('auth.login.newUser') }}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10 bg-[#2d2e32]"
severity="secondary"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.login.loginWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
severity="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignInData } from '@/schemas/signInSchema'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
const navigateToSignup = () => {
void router.push({ name: 'cloud-signup', query: route.query })
}
const onSuccess = async () => {
toastStore.add({
severity: 'success',
summary: 'Login Completed',
life: 2000
})
await router.push({ name: 'cloud-user-check' })
}
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle()) {
await onSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub()) {
await onSuccess()
}
}
const signInWithEmail = async (values: SignInData) => {
authError.value = ''
if (await authActions.signInWithEmail(values.email, values.password)) {
await onSuccess()
}
}
</script>

View File

@@ -0,0 +1,177 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('auth.signup.title') }}
</h1>
<p class="my-0 text-base">
<span class="text-muted">{{
t('auth.signup.alreadyHaveAccount')
}}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="navigateToLogin"
>{{ t('auth.signup.signIn') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10 bg-[#2d2e32]"
severity="secondary"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
severity="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<!-- Terms & Contact -->
<div class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
<p class="mt-2">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudWaitlist_contactLink') }}</a
>.
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const navigateToLogin = () => {
void router.push({ name: 'cloud-login', query: route.query })
}
const onSuccess = async () => {
toastStore.add({
severity: 'success',
summary: 'Sign up Completed',
life: 2000
})
// Direct redirect to main app - email verification removed
await router.push({ path: '/', query: route.query })
}
const signInWithGoogle = async () => {
authError.value = ''
if (await authActions.signInWithGoogle()) {
await onSuccess()
}
}
const signInWithGithub = async () => {
authError.value = ''
if (await authActions.signInWithGithub()) {
await onSuccess()
}
}
const signUpWithEmail = async (values: SignUpData) => {
authError.value = ''
if (await authActions.signUpWithEmail(values.email, values.password)) {
await onSuccess()
}
}
onMounted(async () => {
// Track signup screen opened
if (isCloud) {
useTelemetry()?.trackSignupOpened()
}
userIsInChina.value = await isInChina()
})
</script>
<style scoped>
:deep(.p-inputtext) {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
}
:deep(.p-password input) {
border: none !important;
box-shadow: none !important;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<div class="cloud-sorry-contact-support">
<h1>{{ t('cloudSorryContactSupport_title') }}</h1>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style scoped>
.cloud-sorry-contact-support {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: monospace;
font-size: 1.5rem;
}
</style>

View File

@@ -0,0 +1,387 @@
<template>
<div>
<Stepper
value="1"
class="flex h-[638px] max-h-[80vh] w-[320px] max-w-[90vw] flex-col"
>
<ProgressBar
:value="progressPercent"
:show-value="false"
class="mb-8 h-2"
/>
<StepPanels class="flex flex-1 flex-col p-0">
<StepPanel
v-slot="{ activateCallback }"
value="1"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_familiarity')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in familiarityOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.familiarity"
:input-id="`fam-${opt.value}`"
name="familiarity"
:value="opt.value"
/>
<label
:for="`fam-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
</div>
<div class="flex justify-between pt-4">
<span />
<Button
label="Next"
:disabled="!validStep1"
class="h-10 w-full border-none text-white"
@click="goTo(2, activateCallback)"
/>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="2"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_purpose')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in purposeOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.useCase"
:input-id="`purpose-${opt.value}`"
name="purpose"
:value="opt.value"
/>
<label
:for="`purpose-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
<div v-if="surveyData.useCase === 'other'" class="mt-4 ml-8">
<InputText
v-model="surveyData.useCaseOther"
class="w-full"
placeholder="Please specify"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
label="Back"
severity="secondary"
class="flex-1 text-white"
@click="goTo(1, activateCallback)"
/>
<Button
label="Next"
:disabled="!validStep2"
class="h-10 flex-1 text-white"
@click="goTo(3, activateCallback)"
/>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="3"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_industry')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in industryOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<RadioButton
v-model="surveyData.industry"
:input-id="`industry-${opt.value}`"
name="industry"
:value="opt.value"
/>
<label
:for="`industry-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
<div v-if="surveyData.industry === 'other'" class="mt-4 ml-8">
<InputText
v-model="surveyData.industryOther"
class="w-full"
placeholder="Please specify"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
label="Back"
severity="secondary"
class="flex-1 text-white"
@click="goTo(2, activateCallback)"
/>
<Button
label="Next"
:disabled="!validStep3"
class="h-10 flex-1 border-none text-white"
@click="goTo(4, activateCallback)"
/>
</div>
</StepPanel>
<StepPanel
v-slot="{ activateCallback }"
value="4"
class="flex min-h-full flex-1 flex-col justify-between bg-transparent"
>
<div>
<label class="mb-8 block text-lg font-medium">{{
t('cloudSurvey_steps_making')
}}</label>
<div class="flex flex-col gap-6">
<div
v-for="opt in makingOptions"
:key="opt.value"
class="flex items-center gap-3"
>
<Checkbox
v-model="surveyData.making"
:input-id="`making-${opt.value}`"
:value="opt.value"
/>
<label
:for="`making-${opt.value}`"
class="cursor-pointer text-sm"
>{{ opt.label }}</label
>
</div>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
label="Back"
severity="secondary"
class="flex-1 text-white"
@click="goTo(3, activateCallback)"
/>
<Button
label="Submit"
:disabled="!validStep4 || isSubmitting"
:loading="isSubmitting"
class="h-10 flex-1 border-none text-white"
@click="onSubmitSurvey"
/>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import InputText from 'primevue/inputtext'
import ProgressBar from 'primevue/progressbar'
import RadioButton from 'primevue/radiobutton'
import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
getSurveyCompletedStatus,
submitSurvey
} from '@/platform/cloud/onboarding/auth'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
const { t } = useI18n()
const router = useRouter()
// Check if survey is already completed on mount
onMounted(async () => {
try {
const surveyCompleted = await getSurveyCompletedStatus()
if (surveyCompleted) {
// User already completed survey, redirect to waitlist
await router.replace({ name: 'cloud-waitlist' })
} else {
// Track survey opened event
if (isCloud) {
useTelemetry()?.trackSurvey('opened')
}
}
} catch (error) {
console.error('Failed to check survey status:', error)
}
})
const activeStep = ref(1)
const totalSteps = 4
const progressPercent = computed(() =>
Math.max(20, Math.min(100, ((activeStep.value - 1) / (totalSteps - 1)) * 100))
)
const isSubmitting = ref(false)
const surveyData = ref({
familiarity: '',
useCase: '',
useCaseOther: '',
industry: '',
industryOther: '',
making: [] as string[]
})
// Options
const familiarityOptions = [
{ label: 'New to ComfyUI (never used it before)', value: 'new' },
{ label: 'Just getting started (following tutorials)', value: 'starting' },
{ label: 'Comfortable with basics', value: 'basics' },
{ label: 'Advanced user (custom workflows)', value: 'advanced' },
{ label: 'Expert (help others)', value: 'expert' }
]
const purposeOptions = [
{ label: 'Personal projects/hobby', value: 'personal' },
{
label: 'Community contributions (nodes, workflows, etc.)',
value: 'community'
},
{ label: 'Client work (freelance)', value: 'client' },
{ label: 'My own workplace (in-house)', value: 'inhouse' },
{ label: 'Academic research', value: 'research' },
{ label: 'Other', value: 'other' }
]
const industryOptions = [
{ label: 'Film, TV, & animation', value: 'film_tv_animation' },
{ label: 'Gaming', value: 'gaming' },
{ label: 'Marketing & advertising', value: 'marketing' },
{ label: 'Architecture', value: 'architecture' },
{ label: 'Product & graphic design', value: 'product_design' },
{ label: 'Fine art & illustration', value: 'fine_art' },
{ label: 'Software & technology', value: 'software' },
{ label: 'Education', value: 'education' },
{ label: 'Other', value: 'other' }
]
const makingOptions = [
{ label: 'Images', value: 'images' },
{ label: 'Video & animation', value: 'video' },
{ label: '3D assets', value: '3d' },
{ label: 'Audio/music', value: 'audio' },
{ label: 'Custom nodes & workflows', value: 'custom_nodes' }
]
// Validation per step
const validStep1 = computed(() => !!surveyData.value.familiarity)
const validStep2 = computed(() => {
if (!surveyData.value.useCase) return false
if (surveyData.value.useCase === 'other') {
return !!surveyData.value.useCaseOther?.trim()
}
return true
})
const validStep3 = computed(() => {
if (!surveyData.value.industry) return false
if (surveyData.value.industry === 'other') {
return !!surveyData.value.industryOther?.trim()
}
return true
})
const validStep4 = computed(() => surveyData.value.making.length > 0)
const changeActiveStep = (step: number) => {
activeStep.value = step
}
const goTo = (step: number, activate: (val: string | number) => void) => {
// keep Stepper panel and progress bar in sync; Stepper values are strings
changeActiveStep(step)
activate(String(step))
}
// Submit
const onSubmitSurvey = async () => {
try {
isSubmitting.value = true
// prepare payload with consistent structure
const payload = {
familiarity: surveyData.value.familiarity,
useCase:
surveyData.value.useCase === 'other'
? surveyData.value.useCaseOther?.trim() || 'other'
: surveyData.value.useCase,
industry:
surveyData.value.industry === 'other'
? surveyData.value.industryOther?.trim() || 'other'
: surveyData.value.industry,
making: surveyData.value.making
}
await submitSurvey(payload)
// Track survey submitted event with responses
if (isCloud) {
useTelemetry()?.trackSurvey('submitted', {
industry: payload.industry,
useCase: payload.useCase,
familiarity: payload.familiarity,
making: payload.making
})
}
await router.push({ name: 'cloud-user-check' })
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
:deep(.p-progressbar .p-progressbar-value) {
background-color: #f0ff41 !important;
}
:deep(.p-radiobutton-checked .p-radiobutton-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<CloudLoginViewSkeleton v-if="skeletonType === 'login'" />
<CloudSurveyViewSkeleton v-else-if="skeletonType === 'survey'" />
<CloudWaitlistViewSkeleton v-else-if="skeletonType === 'waitlist'" />
<div v-else-if="error" class="flex h-full items-center justify-center p-8">
<div class="max-w-[100vw] p-2 text-center lg:w-96">
<p class="mb-4 text-red-500">{{ errorMessage }}</p>
<Button
:label="
isRetrying
? $t('cloudOnboarding.retrying')
: $t('cloudOnboarding.retry')
"
:loading="isRetrying"
class="w-full"
@click="handleRetry"
/>
</div>
</div>
<div v-else class="flex items-center justify-center">
<ProgressSpinner class="h-8 w-8" />
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useErrorHandling } from '@/composables/useErrorHandling'
import {
getSurveyCompletedStatus,
getUserCloudStatus
} from '@/platform/cloud/onboarding/auth'
import CloudLoginViewSkeleton from './skeletons/CloudLoginViewSkeleton.vue'
import CloudSurveyViewSkeleton from './skeletons/CloudSurveyViewSkeleton.vue'
const router = useRouter()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const skeletonType = ref<'login' | 'survey' | 'waitlist' | 'loading'>('loading')
const {
isLoading,
error,
execute: checkUserStatus
} = useAsyncState(
wrapWithErrorHandlingAsync(async () => {
await nextTick()
const [cloudUserStats, surveyStatus] = await Promise.all([
getUserCloudStatus(),
getSurveyCompletedStatus()
])
// Navigate based on user status
if (!cloudUserStats) {
skeletonType.value = 'login'
await router.replace({ name: 'cloud-login' })
return
}
// Survey is required for all users
if (!surveyStatus) {
skeletonType.value = 'survey'
await router.replace({ name: 'cloud-survey' })
return
}
// User is fully onboarded (active or whitelist check disabled)
window.location.href = '/'
}),
null,
{ resetOnExecute: false }
)
const errorMessage = computed(() => {
if (!error.value) return ''
// Provide user-friendly error messages
const errorStr = error.value.toString().toLowerCase()
if (errorStr.includes('network') || errorStr.includes('fetch')) {
return 'Connection problem. Please check your internet connection.'
}
if (errorStr.includes('timeout')) {
return 'Request timed out. Please try again.'
}
return 'Unable to check account status. Please try again.'
})
const isRetrying = computed(() => isLoading.value && !!error.value)
const handleRetry = async () => {
await checkUserStatus()
}
</script>

View File

@@ -0,0 +1,33 @@
/* ABC ROM Extended — full face mapping */
@font-face {
font-family: 'ABC ROM Extended';
src:
local('ABC ROM Extended Black Italic'),
local('ABCRom BlackItalic'),
url('../fonts/ABCROMExtended-BlackItalic.woff2') format('woff2'),
url('../fonts/ABCROMExtended-BlackItalic.woff') format('woff');
font-weight: 900;
font-style: italic;
font-display: swap;
}
/* Prevent browser from synthesizing fake bold/italic which can cause mismatches */
.hero-title,
.font-abcrom {
font-family: 'ABC ROM Extended', sans-serif;
font-synthesis: none; /* no faux bold/italic */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Figma-like hero style */
.hero-title {
font-size: 32px;
font-weight: 900;
font-style: italic;
text-transform: uppercase;
text-shadow: 0 4px 4px rgb(0 0 0 / 0.25);
/* Figma has leading-trim/text-edge which CSS doesn't support; emulate with tight line-height */
line-height: 1.1;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

View File

@@ -0,0 +1,235 @@
import * as Sentry from '@sentry/vue'
import { isEmpty } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
interface UserCloudStatus {
status: 'active'
}
const ONBOARDING_SURVEY_KEY = 'onboarding_survey'
/**
* Helper function to capture API errors with Sentry
*/
function captureApiError(
error: Error,
endpoint: string,
errorType: 'http_error' | 'network_error',
httpStatus?: number,
operation?: string,
extraContext?: Record<string, any>
) {
const tags: Record<string, any> = {
api_endpoint: endpoint,
error_type: errorType
}
if (httpStatus !== undefined) {
tags.http_status = httpStatus
}
if (operation) {
tags.operation = operation
}
const sentryOptions: any = {
tags,
extra: extraContext ? { ...extraContext } : undefined
}
Sentry.captureException(error, sentryOptions)
}
/**
* Helper function to check if error is already handled HTTP error
*/
function isHttpError(error: unknown, errorMessagePrefix: string): boolean {
return error instanceof Error && error.message.startsWith(errorMessagePrefix)
}
export async function getUserCloudStatus(): Promise<UserCloudStatus> {
try {
const response = await api.fetchApi('/user', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const error = new Error(`Failed to get user: ${response.statusText}`)
captureApiError(
error,
'/user',
'http_error',
response.status,
undefined,
{
api: {
method: 'GET',
endpoint: '/user',
status_code: response.status,
status_text: response.statusText
}
}
)
throw error
}
return response.json()
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to get user:')) {
captureApiError(error as Error, '/user', 'network_error')
}
throw error
}
}
export async function getSurveyCompletedStatus(): Promise<boolean> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
// Not an error case - survey not completed is a valid state
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey status check returned non-ok response',
level: 'info',
data: {
status: response.status,
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
}
})
return false
}
const data = await response.json()
// Check if data exists and is not empty
return !isEmpty(data.value)
} catch (error) {
// Network error - still capture it as it's not thrown from above
Sentry.captureException(error, {
tags: {
api_endpoint: '/settings/{key}',
error_type: 'network_error'
},
extra: {
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
},
level: 'warning'
})
return false
}
}
// @ts-expect-error - Unused function kept for future use
async function postSurveyStatus(): Promise<void> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
})
if (!response.ok) {
const error = new Error(
`Failed to post survey status: ${response.statusText}`
)
captureApiError(
error,
'/settings/{key}',
'http_error',
response.status,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
throw error
}
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to post survey status:')) {
captureApiError(
error as Error,
'/settings/{key}',
'network_error',
undefined,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
}
throw error
}
}
export async function submitSurvey(
survey: Record<string, unknown>
): Promise<void> {
try {
Sentry.addBreadcrumb({
category: 'auth',
message: 'Submitting survey',
level: 'info',
data: {
survey_fields: Object.keys(survey)
}
})
const response = await api.fetchApi('/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: survey })
})
if (!response.ok) {
const error = new Error(`Failed to submit survey: ${response.statusText}`)
captureApiError(
error,
'/settings',
'http_error',
response.status,
'submit_survey',
{
survey: {
field_count: Object.keys(survey).length,
field_names: Object.keys(survey)
}
}
)
throw error
}
// Log successful survey submission
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey submitted successfully',
level: 'info'
})
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to submit survey:')) {
captureApiError(
error as Error,
'/settings',
'network_error',
undefined,
'submit_survey'
)
}
throw error
}
}

View File

@@ -0,0 +1,16 @@
<template>
<CloudTemplate>
<!-- This will render the nested route components -->
<RouterView />
</CloudTemplate>
<!-- Global Toast for displaying notifications -->
<GlobalToast />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import CloudTemplate from './CloudTemplate.vue'
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
<img
src="/assets/images/comfy-cloud-logo.svg"
alt="Comfy Cloud Logo"
class="h-3/4 max-h-10 w-auto"
/>
</div>
</template>

View File

@@ -0,0 +1,128 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signInSchema)"
@submit="onSubmit"
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label class="mb-2 text-base font-medium opacity-80" :for="emailInputId">
{{ t('auth.login.emailLabel') }}
</label>
<InputText
:id="emailInputId"
autocomplete="email"
class="h-10"
name="email"
type="text"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="$form.email?.invalid"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
}}</small>
</div>
<!-- Password Field -->
<div class="flex flex-col gap-2">
<div class="mb-2 flex items-center justify-between">
<label
class="text-base font-medium opacity-80"
for="cloud-sign-in-password"
>
{{ t('auth.login.passwordLabel') }}
</label>
</div>
<Password
input-id="cloud-sign-in-password"
pt:pc-input-text:root:autocomplete="current-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.passwordPlaceholder')"
:class="{ 'p-invalid': $form.password?.invalid }"
fluid
class="h-10"
/>
<small v-if="$form.password?.invalid" class="text-red-500">{{
$form.password.error.message
}}</small>
<router-link
:to="{ name: 'cloud-forgot-password' }"
class="text-sm font-medium text-muted no-underline"
>
{{ t('auth.login.forgotPassword') }}
</router-link>
</div>
<!-- Auth Error Message -->
<Message v-if="authError" severity="error">
{{ authError }}
</Message>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
type="submit"
:label="t('auth.login.loginButton')"
class="mt-4 h-10 font-medium text-white"
/>
</Form>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const loading = computed(() => authStore.loading)
const { t } = useI18n()
defineProps<{
authError?: string
}>()
const emit = defineEmits<{
submit: [values: SignInData]
}>()
const emailInputId = 'cloud-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
</script>
<style scoped>
:deep(.p-inputtext) {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
}
:deep(.p-password input) {
border: none !important;
box-shadow: none !important;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="flex">
<BaseViewTemplate dark class="flex-1">
<template #header>
<CloudLogo />
</template>
<slot />
<template #footer>
<CloudTemplateFooter />
</template>
</BaseViewTemplate>
<div class="relative hidden flex-1 overflow-hidden bg-black lg:block">
<!-- Video Background -->
<video
class="absolute inset-0 h-full w-full object-cover"
autoplay
muted
loop
playsinline
:poster="videoPoster"
>
<source :src="videoSrc" type="video/mp4" />
</video>
<div class="absolute inset-0 h-full w-full bg-black/30"></div>
<!-- Optional Overlay for better visual -->
<div
class="absolute inset-0 flex items-center justify-center text-center text-white"
>
<div>
<h1 class="font-abcrom hero-title font-black uppercase italic">
{{ t('cloudStart_title') }}
</h1>
<p class="m-2 text-center text-xl text-white">
{{ t('cloudStart_desc') }}
</p>
<p class="m-0 text-center text-xl text-white">
{{ t('cloudStart_explain') }}
</p>
</div>
</div>
<div class="absolute inset-0 flex flex-col justify-end px-14 pb-[64px]">
<div class="flex items-center justify-end">
<div class="flex items-center gap-3">
<p class="text-md text-white">
{{ t('cloudStart_wantToRun') }}
</p>
<Button
type="button"
class="h-10 bg-black font-bold text-white"
severity="secondary"
@click="handleDownloadClick"
>
{{ t('cloudStart_download') }}
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { t } from '@/i18n'
import videoPoster from '@/platform/cloud/onboarding/assets/videos/thumbnail.png'
import videoSrc from '@/platform/cloud/onboarding/assets/videos/video.mp4'
import CloudLogo from '@/platform/cloud/onboarding/components/CloudLogo.vue'
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const handleDownloadClick = () => {
window.open('https://www.comfy.org/download', '_blank')
}
</script>
<style>
@import '../assets/css/fonts.css';
</style>

View File

@@ -0,0 +1,32 @@
<template>
<footer class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start gap-2.5">
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-sm text-gray-600 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-sm text-gray-600 no-underline"
>
{{ t('auth.login.privacyLink') }}
</a>
<a
href="https://support.comfy.org"
class="cursor-pointer text-sm text-gray-600 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudFooter_needHelp') }}
</a>
</footer>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,72 @@
import type { RouteRecordRaw } from 'vue-router'
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
{
path: '/cloud',
component: () =>
import('@/platform/cloud/onboarding/components/CloudLayoutView.vue'),
children: [
{
path: 'login',
name: 'cloud-login',
component: () =>
import('@/platform/cloud/onboarding/CloudLoginView.vue'),
beforeEnter: async (to, _from, next) => {
// Only redirect if not explicitly switching accounts
if (!to.query.switchAccount) {
const { useCurrentUser } = await import(
'@/composables/auth/useCurrentUser'
)
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
// User is already logged in, redirect to user-check
// user-check will handle survey, or main page routing
return next({ name: 'cloud-user-check' })
}
}
next()
}
},
{
path: 'signup',
name: 'cloud-signup',
component: () =>
import('@/platform/cloud/onboarding/CloudSignupView.vue')
},
{
path: 'forgot-password',
name: 'cloud-forgot-password',
component: () =>
import('@/platform/cloud/onboarding/CloudForgotPasswordView.vue')
},
{
path: 'survey',
name: 'cloud-survey',
component: () =>
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
meta: { requiresAuth: true }
},
{
path: 'user-check',
name: 'cloud-user-check',
component: () =>
import('@/platform/cloud/onboarding/UserCheckView.vue'),
meta: { requiresAuth: true }
},
{
path: 'sorry-contact-support',
name: 'cloud-sorry-contact-support',
component: () =>
import('@/platform/cloud/onboarding/CloudSorryContactSupportView.vue')
},
{
path: 'auth-timeout',
name: 'cloud-auth-timeout',
component: () =>
import('@/platform/cloud/onboarding/CloudAuthTimeoutView.vue'),
props: true
}
]
}
]

View File

@@ -0,0 +1,47 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-[100vw] lg:w-96">
<div class="rounded-lg bg-[#2d2e32] p-4">
<Skeleton width="60%" height="1.125rem" class="mb-2" />
<Skeleton width="90%" height="1rem" class="mb-2" />
<Skeleton width="80%" height="1rem" />
</div>
<div class="mt-6 mb-8 flex flex-col gap-4">
<Skeleton width="45%" height="1.5rem" class="my-0" />
<div class="flex items-center">
<Skeleton width="25%" height="1rem" class="mr-1" />
<Skeleton width="20%" height="1rem" />
</div>
</div>
<div class="mb-8">
<Skeleton width="20%" height="1rem" class="mb-2" />
<Skeleton width="100%" height="2.5rem" class="mb-4" />
<Skeleton width="25%" height="1rem" class="mb-4" />
<Skeleton width="100%" height="2.5rem" class="mb-6" />
<Skeleton width="80%" height="1rem" class="mb-4" />
<Skeleton width="100%" height="2.5rem" />
</div>
<div class="my-8 flex items-center">
<div class="flex-1 border-t border-gray-300"></div>
<Skeleton width="30%" height="1rem" class="mx-4" />
<div class="flex-1 border-t border-gray-300"></div>
</div>
<div class="flex flex-col gap-6">
<Skeleton width="100%" height="2.5rem" />
<Skeleton width="100%" height="2.5rem" />
</div>
<div class="mt-5">
<Skeleton width="70%" height="0.875rem" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div>
<div class="flex min-h-[638px] min-w-[320px] flex-col">
<Skeleton width="100%" height="0.5rem" class="mb-8" />
<div class="flex flex-1 flex-col p-0">
<div class="flex min-h-full flex-1 flex-col justify-between">
<div>
<Skeleton width="70%" height="1.75rem" class="mb-8" />
<div class="flex flex-col gap-6">
<div v-for="i in 5" :key="i" class="flex items-center gap-3">
<Skeleton width="1.25rem" height="1.25rem" shape="circle" />
<Skeleton width="85%" height="0.875rem" />
</div>
</div>
</div>
<div class="flex justify-between pt-4">
<span />
<Skeleton width="100%" height="2.5rem" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,11 +1,11 @@
<template>
<Button
v-tooltip.bottom="{
value: $t('subscription.subscribeToRun'),
value: $t('subscription.subscribeToRunFull'),
showDelay: 600
}"
class="subscribe-to-run-button"
:label="$t('subscription.subscribeToRun')"
:label="buttonLabel"
icon="pi pi-lock"
severity="primary"
size="small"
@@ -15,6 +15,7 @@
}"
:pt="{
root: {
class: 'whitespace-nowrap',
style: {
borderColor: 'transparent'
}
@@ -26,12 +27,25 @@
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMdOrLarger = breakpoints.greaterOrEqual('md')
const buttonLabel = computed(() =>
isMdOrLarger.value
? t('subscription.subscribeToRunFull')
: t('subscription.subscribeToRun')
)
const { showSubscriptionDialog } = useSubscription()
const handleSubscribeToRun = () => {

View File

@@ -4,7 +4,7 @@ import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
@@ -74,6 +74,8 @@ function useSubscriptionInternal() {
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
)
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
@@ -114,7 +116,7 @@ function useSubscriptionInternal() {
}
const handleViewUsageHistory = () => {
window.open('https://platform.comfy.org/profile/usage', '_blank')
window.open(`${getComfyPlatformBaseUrl()}/profile/usage`, '_blank')
}
const handleLearnMore = () => {
@@ -136,7 +138,7 @@ function useSubscriptionInternal() {
}
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
buildApiUrl('/customers/cloud-subscription-status'),
{
headers: {
...authHeader,
@@ -181,7 +183,7 @@ function useSubscriptionInternal() {
}
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-checkout`,
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers: {

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