mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 00:39:49 +00:00
Compare commits
39 Commits
claude/sla
...
cloud/1.36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f5b36383 | ||
|
|
2da833854a | ||
|
|
15955bd940 | ||
|
|
8e62359af9 | ||
|
|
fca4931f52 | ||
|
|
eeb0fd354a | ||
|
|
c28e60e5a8 | ||
|
|
dc0d85990d | ||
|
|
74ebfec582 | ||
|
|
bc4b3d0a95 | ||
|
|
4a6a1287ce | ||
|
|
9bf5176b8f | ||
|
|
056308a026 | ||
|
|
fe48900c56 | ||
|
|
19db196727 | ||
|
|
4963f59264 | ||
|
|
7f83af391c | ||
|
|
2d04cf4757 | ||
|
|
89a00fe459 | ||
|
|
7158e81a4d | ||
|
|
53d76c42c2 | ||
|
|
1abe3f2707 | ||
|
|
e84b6f3696 | ||
|
|
348d674455 | ||
|
|
4165f52109 | ||
|
|
b53976c775 | ||
|
|
ec7a3a9e20 | ||
|
|
be8ee3d228 | ||
|
|
b9e6f3d9fa | ||
|
|
e912b42fff | ||
|
|
aecb841cc0 | ||
|
|
b4e4cccc31 | ||
|
|
2a15325d81 | ||
|
|
f1b874eeed | ||
|
|
1de23b8aa2 | ||
|
|
e9c47e80b9 | ||
|
|
8eb2ba0745 | ||
|
|
4957cd356b | ||
|
|
9f3bbb94fd |
@@ -6,6 +6,7 @@ const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
module.exports = defineConfig({
|
||||
modelName: 'gpt-4.1',
|
||||
splitToken: 1024,
|
||||
saveImmediately: true,
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: ['@storybook/addon-docs'],
|
||||
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
options: {}
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -63,6 +63,9 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Imports:
|
||||
- sorted/grouped by plugin
|
||||
- run `pnpm format` before committing
|
||||
- use separate `import type` statements, not inline `type` in mixed imports
|
||||
- ✅ `import type { Foo } from './foo'` + `import { bar } from './foo'`
|
||||
- ❌ `import { bar, type Foo } from './foo'`
|
||||
- ESLint:
|
||||
- Vue + TS rules
|
||||
- no floating promises
|
||||
@@ -119,7 +122,10 @@ The project uses **Nx** for build orchestration and task management
|
||||
- Prefer reactive props destructuring to `const props = defineProps<...>`
|
||||
- Do not use `withDefaults` or runtime props declaration
|
||||
- Do not import Vue macros unnecessarily
|
||||
- Prefer `useModel` to separately defining a prop and emit
|
||||
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
|
||||
- Define slots via template usage, not `defineSlots`
|
||||
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
|
||||
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
|
||||
- Be judicious with addition of new refs or other state
|
||||
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
|
||||
- If it's possible to use the `ref` or prop directly, don't add a `computed`
|
||||
@@ -137,7 +143,7 @@ The project uses **Nx** for build orchestration and task management
|
||||
8. Implement proper error handling
|
||||
9. Follow Vue 3 style guide and naming conventions
|
||||
10. Use Vite for fast development and building
|
||||
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
|
||||
12. Avoid new usage of PrimeVue components
|
||||
13. Write tests for all changes, especially bug fixes to catch future regressions
|
||||
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
|
||||
@@ -155,6 +161,8 @@ The project uses **Nx** for build orchestration and task management
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
See @docs/testing/*.md for detailed patterns.
|
||||
|
||||
- Frameworks:
|
||||
- Vitest (unit/component, happy-dom)
|
||||
- Playwright (E2E)
|
||||
@@ -268,6 +276,8 @@ When referencing Comfy-Org repos:
|
||||
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
|
||||
- NEVER use `!important` or the `!` important prefix for tailwind classes
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
AMD and the AMD Arrow logo are trademarks of Advanced Micro Devices, Inc.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/desktop-ui",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.4",
|
||||
"type": "module",
|
||||
"nx": {
|
||||
"tags": [
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,84 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import type {
|
||||
ElectronAPI,
|
||||
TorchDeviceType
|
||||
} from '@comfyorg/comfyui-electron-types'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import GpuPicker from './GpuPicker.vue'
|
||||
|
||||
type Platform = ReturnType<ElectronAPI['getPlatform']>
|
||||
type ElectronAPIStub = Pick<ElectronAPI, 'getPlatform'>
|
||||
type WindowWithElectron = Window & { electronAPI?: ElectronAPIStub }
|
||||
|
||||
const meta: Meta<typeof GpuPicker> = {
|
||||
title: 'Desktop/Components/GpuPicker',
|
||||
component: GpuPicker,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function createElectronDecorator(platform: Platform) {
|
||||
function getPlatform() {
|
||||
return platform
|
||||
}
|
||||
|
||||
return function ElectronDecorator() {
|
||||
const windowWithElectron = window as WindowWithElectron
|
||||
windowWithElectron.electronAPI = { getPlatform }
|
||||
return { template: '<story />' }
|
||||
}
|
||||
}
|
||||
|
||||
function renderWithDevice(device: TorchDeviceType | null) {
|
||||
return function Render() {
|
||||
return {
|
||||
components: { GpuPicker },
|
||||
setup() {
|
||||
const selected = ref<TorchDeviceType | null>(device)
|
||||
return { selected }
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8">
|
||||
<GpuPicker v-model:device="selected" />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const windowsDecorator = createElectronDecorator('win32')
|
||||
const macDecorator = createElectronDecorator('darwin')
|
||||
|
||||
export const WindowsNvidiaSelected: Story = {
|
||||
decorators: [windowsDecorator],
|
||||
render: renderWithDevice('nvidia')
|
||||
}
|
||||
|
||||
export const WindowsAmdSelected: Story = {
|
||||
decorators: [windowsDecorator],
|
||||
render: renderWithDevice('amd')
|
||||
}
|
||||
|
||||
export const WindowsCpuSelected: Story = {
|
||||
decorators: [windowsDecorator],
|
||||
render: renderWithDevice('cpu')
|
||||
}
|
||||
|
||||
export const MacMpsSelected: Story = {
|
||||
decorators: [macDecorator],
|
||||
render: renderWithDevice('mps')
|
||||
}
|
||||
@@ -11,32 +11,29 @@
|
||||
<!-- Apple Metal / NVIDIA -->
|
||||
<HardwareOption
|
||||
v-if="platform === 'darwin'"
|
||||
image-path="./assets/images/apple-mps-logo.png"
|
||||
:image-path="'./assets/images/apple-mps-logo.png'"
|
||||
placeholder-text="Apple Metal"
|
||||
subtitle="Apple Metal"
|
||||
:value="'mps'"
|
||||
:selected="selected === 'mps'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('mps')"
|
||||
/>
|
||||
<template v-else>
|
||||
<HardwareOption
|
||||
image-path="./assets/images/nvidia-logo-square.jpg"
|
||||
placeholder-text="NVIDIA"
|
||||
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
|
||||
:selected="selected === 'nvidia'"
|
||||
@click="pickGpu('nvidia')"
|
||||
/>
|
||||
<HardwareOption
|
||||
image-path="./assets/images/amd-rocm-logo.png"
|
||||
placeholder-text="AMD"
|
||||
:subtitle="$t('install.gpuPicker.amdSubtitle')"
|
||||
:selected="selected === 'amd'"
|
||||
@click="pickGpu('amd')"
|
||||
/>
|
||||
</template>
|
||||
<HardwareOption
|
||||
v-else
|
||||
:image-path="'./assets/images/nvidia-logo-square.jpg'"
|
||||
placeholder-text="NVIDIA"
|
||||
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
|
||||
:value="'nvidia'"
|
||||
:selected="selected === 'nvidia'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('nvidia')"
|
||||
/>
|
||||
<!-- CPU -->
|
||||
<HardwareOption
|
||||
placeholder-text="CPU"
|
||||
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
|
||||
:value="'cpu'"
|
||||
:selected="selected === 'cpu'"
|
||||
@click="pickGpu('cpu')"
|
||||
/>
|
||||
@@ -44,6 +41,7 @@
|
||||
<HardwareOption
|
||||
placeholder-text="Manual Install"
|
||||
:subtitle="$t('install.gpuPicker.manualSubtitle')"
|
||||
:value="'unsupported'"
|
||||
:selected="selected === 'unsupported'"
|
||||
@click="pickGpu('unsupported')"
|
||||
/>
|
||||
@@ -83,15 +81,13 @@ const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
const electron = electronAPI()
|
||||
const platform = electron.getPlatform()
|
||||
|
||||
const recommendedDevices: TorchDeviceType[] = ['mps', 'nvidia', 'amd']
|
||||
const showRecommendedBadge = computed(() =>
|
||||
selected.value ? recommendedDevices.includes(selected.value) : false
|
||||
const showRecommendedBadge = computed(
|
||||
() => selected.value === 'mps' || selected.value === 'nvidia'
|
||||
)
|
||||
|
||||
const descriptionKeys = {
|
||||
mps: 'appleMetal',
|
||||
nvidia: 'nvidia',
|
||||
amd: 'amd',
|
||||
cpu: 'cpu',
|
||||
unsupported: 'manual'
|
||||
} as const
|
||||
@@ -101,7 +97,7 @@ const descriptionText = computed(() => {
|
||||
return st(`install.gpuPicker.${key}Description`, '')
|
||||
})
|
||||
|
||||
function pickGpu(value: TorchDeviceType) {
|
||||
const pickGpu = (value: TorchDeviceType) => {
|
||||
selected.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,6 +29,7 @@ export const AppleMetalSelected: Story = {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
@@ -38,6 +39,7 @@ export const AppleMetalUnselected: Story = {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -46,6 +48,7 @@ export const CPUOption: Story = {
|
||||
args: {
|
||||
placeholderText: 'CPU',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'cpu',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -54,6 +57,7 @@ export const ManualInstall: Story = {
|
||||
args: {
|
||||
placeholderText: 'Manual Install',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'unsupported',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -63,6 +67,7 @@ export const NvidiaSelected: Story = {
|
||||
imagePath: '/assets/images/nvidia-logo-square.jpg',
|
||||
placeholderText: 'NVIDIA',
|
||||
subtitle: 'NVIDIA',
|
||||
value: 'nvidia',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,13 +36,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface Props {
|
||||
imagePath?: string
|
||||
placeholderText: string
|
||||
subtitle?: string
|
||||
value: TorchDeviceType
|
||||
selected?: boolean
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import { isInChina } from '@comfyorg/shared-frontend-utils/networkUtil'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
@@ -155,7 +155,7 @@ const activeAccordionIndex = ref<string[] | undefined>(undefined)
|
||||
const electron = electronAPI()
|
||||
|
||||
// Mirror configuration logic
|
||||
function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
@@ -170,7 +170,6 @@ function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'amd':
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -63,6 +63,7 @@ const taskStore = useMaintenanceTaskStore()
|
||||
defineProps<{
|
||||
displayAsList: string
|
||||
filter: MaintenanceFilter
|
||||
isRefreshing: boolean
|
||||
}>()
|
||||
|
||||
const executeTask = async (task: MaintenanceTask) => {
|
||||
|
||||
@@ -143,8 +143,6 @@ const goToPreviousStep = () => {
|
||||
const electron = electronAPI()
|
||||
const router = useRouter()
|
||||
const install = async () => {
|
||||
if (!device.value) return
|
||||
|
||||
const options: InstallOptions = {
|
||||
installPath: installPath.value,
|
||||
autoUpdate: autoUpdate.value,
|
||||
@@ -154,6 +152,7 @@ const install = async () => {
|
||||
pythonMirror: pythonMirror.value,
|
||||
pypiMirror: pypiMirror.value,
|
||||
torchMirror: torchMirror.value,
|
||||
// @ts-expect-error fixme ts strict error
|
||||
device: device.value
|
||||
}
|
||||
electron.installComfyUI(options)
|
||||
@@ -167,11 +166,7 @@ onMounted(async () => {
|
||||
if (!electron) return
|
||||
|
||||
const detectedGpu = await electron.Config.getDetectedGpu()
|
||||
if (
|
||||
detectedGpu === 'mps' ||
|
||||
detectedGpu === 'nvidia' ||
|
||||
detectedGpu === 'amd'
|
||||
) {
|
||||
if (detectedGpu === 'mps' || detectedGpu === 'nvidia') {
|
||||
device.value = detectedGpu
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
||||
:filter
|
||||
:display-as-list
|
||||
:is-refreshing
|
||||
/>
|
||||
|
||||
<!-- Actions -->
|
||||
|
||||
@@ -19,7 +19,6 @@ test.describe('Graph', () => {
|
||||
})
|
||||
|
||||
test('Validate workflow links', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Validation.Workflows', true)
|
||||
await comfyPage.loadWorkflow('links/bad_link')
|
||||
await expect(comfyPage.getVisibleToastCount()).resolves.toBe(2)
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ test.describe('Templates', () => {
|
||||
|
||||
await comfyPage.page
|
||||
.locator(
|
||||
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
|
||||
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
|
||||
)
|
||||
.click()
|
||||
await comfyPage.templates.loadTemplate('default')
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -1,66 +0,0 @@
|
||||
# Template Ranking System
|
||||
|
||||
Usage-based ordering for workflow templates with position bias normalization.
|
||||
|
||||
Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search).
|
||||
|
||||
## Sort Modes
|
||||
|
||||
| Mode | Formula | Description |
|
||||
| -------------- | ------------------------------------------------ | ---------------------- |
|
||||
| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation |
|
||||
| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven |
|
||||
| `newest` | Date sort | Existing |
|
||||
| `alphabetical` | Name sort | Existing |
|
||||
|
||||
Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1.
|
||||
|
||||
## Data Files
|
||||
|
||||
**Usage scores** (generated from Mixpanel):
|
||||
|
||||
```json
|
||||
// In templates/index.json, add to any template:
|
||||
{
|
||||
"name": "some_template",
|
||||
"usage": 1000,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Search rank** (set per-template in workflow_templates repo):
|
||||
|
||||
```json
|
||||
// In templates/index.json, add to any template:
|
||||
{
|
||||
"name": "some_template",
|
||||
"searchRank": 8, // Scale 1-10, default 5
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
| searchRank | Effect |
|
||||
| ---------- | ---------------------------- |
|
||||
| 1-4 | Demote (bury in results) |
|
||||
| 5 | Neutral (default if not set) |
|
||||
| 6-10 | Promote (boost in results) |
|
||||
|
||||
## Position Bias Correction
|
||||
|
||||
Raw usage reflects true preference AND UI position bias. We use linear interpolation:
|
||||
|
||||
```
|
||||
correction = 1 + (position - 1) / (maxPosition - 1)
|
||||
normalizedUsage = rawUsage × correction
|
||||
```
|
||||
|
||||
| Position | Boost |
|
||||
| -------- | ----- |
|
||||
| 1 | 1.0× |
|
||||
| 50 | 1.28× |
|
||||
| 100 | 1.57× |
|
||||
| 175 | 2.0× |
|
||||
|
||||
Templates buried at the bottom get up to 2× boost to compensate for reduced visibility.
|
||||
|
||||
---
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.37.5",
|
||||
"version": "1.36.12",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -42,7 +42,6 @@
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"storybook": "nx storybook",
|
||||
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
|
||||
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
@@ -66,6 +65,7 @@
|
||||
"@prettier/plugin-oxc": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@storybook/addon-docs": "catalog:",
|
||||
"@storybook/addon-mcp": "catalog:",
|
||||
"@storybook/vue3": "catalog:",
|
||||
"@storybook/vue3-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
@@ -187,7 +187,6 @@
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
"vuefire": "catalog:",
|
||||
"wwobjloader2": "catalog:",
|
||||
"yjs": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
@config '../../tailwind.config.ts';
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@theme {
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
844
pnpm-lock.yaml
generated
844
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@comfyorg/comfyui-electron-types': 0.5.5
|
||||
'@eslint/js': ^9.39.1
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
@@ -15,7 +15,7 @@ catalog:
|
||||
'@nx/playwright': 22.2.6
|
||||
'@nx/storybook': 22.2.4
|
||||
'@nx/vite': 22.2.6
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@pinia/testing': ^0.1.5
|
||||
'@playwright/test': ^1.57.0
|
||||
'@prettier/plugin-oxc': ^0.1.3
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -26,9 +26,10 @@ catalog:
|
||||
'@primevue/icons': 4.2.5
|
||||
'@primevue/themes': ^4.2.5
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sentry/vue': ^8.48.0
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
@@ -39,8 +40,8 @@ catalog:
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
'@vitest/ui': ^4.0.16
|
||||
'@vitest/coverage-v8': ^3.2.4
|
||||
'@vitest/ui': ^3.2.0
|
||||
'@vue/test-utils': ^2.4.6
|
||||
'@vueuse/core': ^11.0.0
|
||||
'@vueuse/integrations': ^13.9.0
|
||||
@@ -59,11 +60,11 @@ catalog:
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
firebase: ^11.6.0
|
||||
globals: ^16.5.0
|
||||
happy-dom: ^20.0.11
|
||||
globals: ^15.9.0
|
||||
happy-dom: ^15.11.0
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsdom: ^26.1.0
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
@@ -72,7 +73,7 @@ catalog:
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
pinia: ^2.1.7
|
||||
postcss-html: ^1.8.0
|
||||
prettier: ^3.7.4
|
||||
pretty-bytes: ^7.1.0
|
||||
@@ -96,15 +97,14 @@ catalog:
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
vitest: ^4.0.16
|
||||
vitest: ^3.2.4
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-component-type-helpers: ^3.0.7
|
||||
vue-eslint-parser: ^10.2.0
|
||||
vue-i18n: ^9.14.3
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.2.1
|
||||
vue-tsc: ^3.1.8
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yjs: ^13.6.27
|
||||
zod: ^3.23.8
|
||||
zod-to-json-schema: ^3.24.1
|
||||
|
||||
@@ -92,8 +92,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
@@ -107,10 +106,8 @@ const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
@@ -136,7 +133,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
|
||||
backgroundClass || 'bg-secondary-background'
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -13,8 +12,4 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { backgroundClass } = defineProps<{
|
||||
backgroundClass?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
30
src/components/common/StatusBadge.vue
Normal file
30
src/components/common/StatusBadge.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
|
||||
|
||||
const { label, severity = 'default' } = defineProps<{
|
||||
label: string
|
||||
severity?: Severity
|
||||
}>()
|
||||
|
||||
function badgeClasses(sev: Severity): string {
|
||||
const baseClasses =
|
||||
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
|
||||
|
||||
switch (sev) {
|
||||
case 'danger':
|
||||
return `${baseClasses} bg-destructive-background text-white`
|
||||
case 'contrast':
|
||||
return `${baseClasses} bg-base-foreground text-base-background`
|
||||
case 'warn':
|
||||
return `${baseClasses} bg-warning-background text-base-background`
|
||||
case 'secondary':
|
||||
return `${baseClasses} bg-secondary-background text-base-foreground`
|
||||
default:
|
||||
return `${baseClasses} bg-primary-background text-base-foreground`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeClasses(severity)">{{ label }}</span>
|
||||
</template>
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
>
|
||||
<slot name="actions" :node="props.node" />
|
||||
</div>
|
||||
|
||||
@@ -175,7 +175,6 @@
|
||||
<!-- Actual Template Cards -->
|
||||
<CardContainer
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="compact"
|
||||
@@ -406,8 +405,6 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
@@ -426,30 +423,6 @@ onMounted(() => {
|
||||
sessionStartTime.value = Date.now()
|
||||
})
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const distributions = computed(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
switch (__DISTRIBUTION__) {
|
||||
case 'cloud':
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
case 'localhost':
|
||||
return [TemplateIncludeOnDistributionEnum.Local]
|
||||
case 'desktop':
|
||||
default:
|
||||
if (systemStatsStore.systemStats?.system.os === 'darwin') {
|
||||
return [
|
||||
TemplateIncludeOnDistributionEnum.Desktop,
|
||||
TemplateIncludeOnDistributionEnum.Mac
|
||||
]
|
||||
}
|
||||
return [
|
||||
TemplateIncludeOnDistributionEnum.Desktop,
|
||||
TemplateIncludeOnDistributionEnum.Windows
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Wrap onClose to track session end
|
||||
const onClose = () => {
|
||||
if (isCloud) {
|
||||
@@ -538,9 +511,6 @@ const allTemplates = computed(() => {
|
||||
return workflowTemplatesStore.enhancedTemplates
|
||||
})
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
|
||||
// Filter templates based on selected navigation item
|
||||
const navigationFilteredTemplates = computed(() => {
|
||||
if (!selectedNavItem.value) {
|
||||
@@ -566,36 +536,6 @@ const {
|
||||
resetFilters
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
* Coordinates state between the selected navigation item and the sort order to
|
||||
* create deterministic, predictable behavior.
|
||||
* @param source The origin of the change ('nav' or 'sort').
|
||||
*/
|
||||
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
const isPopularNav = selectedNavItem.value === 'popular'
|
||||
const isPopularSort = sortBy.value === 'popular'
|
||||
|
||||
if (source === 'nav') {
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
|
||||
sortBy.value = 'popular'
|
||||
} else if (!isPopularNav && isPopularSort) {
|
||||
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
} else if (source === 'sort') {
|
||||
// When sort is changed away from 'Popular' while in the 'Popular' category,
|
||||
// reset the category to 'All Templates' to avoid a confusing state.
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
selectedNavItem.value = 'all'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
|
||||
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
|
||||
watch(sortBy, () => coordinateNavAndSort('sort'))
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
const selectedModelObjects = computed({
|
||||
get() {
|
||||
@@ -638,6 +578,9 @@ const cardRefs = ref<HTMLElement[]>([])
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
|
||||
// Search text for model filter
|
||||
const modelSearchText = ref<string>('')
|
||||
|
||||
@@ -702,19 +645,11 @@ const runsOnFilterLabel = computed(() => {
|
||||
|
||||
// Sort options
|
||||
const sortOptions = computed(() => [
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.default', 'Default'),
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.recommended', 'Recommended'),
|
||||
value: 'recommended'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.popular', 'Popular'),
|
||||
value: 'popular'
|
||||
},
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
value: 'vram-low-to-high'
|
||||
@@ -815,7 +750,7 @@ const pageTitle = computed(() => {
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run all operations in parallel for better performance
|
||||
// Run both operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
@@ -828,14 +763,6 @@ const { isLoading } = useAsyncState(
|
||||
}
|
||||
)
|
||||
|
||||
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
|
||||
return (template.includeOnDistributions?.length ?? 0) > 0
|
||||
? distributions.value.some((d) =>
|
||||
template.includeOnDistributions?.includes(d)
|
||||
)
|
||||
: true
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
<Button variant="textonly" size="sm" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
|
||||
@@ -1,180 +1,264 @@
|
||||
<template>
|
||||
<div class="flex w-112 flex-col gap-8 p-8">
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-semibold text-base-foreground m-0">
|
||||
<div class="flex py-8 items-center justify-between px-8">
|
||||
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h1>
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0 w-96">
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground m-0">
|
||||
{{ $t('credits.topUp.creditsDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="isInsufficientCredits"
|
||||
class="text-sm text-muted-foreground m-0 px-8"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
|
||||
<!-- Current Balance Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<UserCredit text-class="text-3xl font-bold" show-credits-only />
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
$t('credits.creditsAvailable')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit Options Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.howManyCredits') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-2">
|
||||
<CreditTopUpOption
|
||||
v-for="option in creditOptions"
|
||||
:key="option.credits"
|
||||
:credits="option.credits"
|
||||
:description="option.description"
|
||||
:selected="selectedCredits === option.credits"
|
||||
@select="selectedCredits = option.credits"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
<i
|
||||
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
<!-- Preset amount buttons -->
|
||||
<div class="px-8">
|
||||
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||
{{ $t('credits.topUp.selectAmount') }}
|
||||
</h3>
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
v-for="amount in PRESET_AMOUNTS"
|
||||
:key="amount"
|
||||
:autofocus="amount === 50"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePresetClick(amount)"
|
||||
>
|
||||
{{ t('subscription.videoTemplateBasedCredits') }}
|
||||
</span>
|
||||
${{ amount }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
:model-value="payAmount"
|
||||
:min="0"
|
||||
:max="MAX_AMOUNT"
|
||||
:step="getStepAmount"
|
||||
@update:model-value="handlePayAmountChange"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="shrink-0 text-base font-semibold text-base-foreground"
|
||||
>$</span
|
||||
>
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
|
||||
<!-- Buy Button -->
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
v-model="creditsModel"
|
||||
:min="0"
|
||||
:max="usdToCredits(MAX_AMOUNT)"
|
||||
:step="getCreditsStepAmount"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
|
||||
<p
|
||||
v-if="isBelowMin"
|
||||
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCeilingWarning"
|
||||
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
})
|
||||
}}
|
||||
<span>{{ $t('credits.topUp.needMore') }}</span>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/enterprise"
|
||||
target="_blank"
|
||||
class="ml-1 text-inherit"
|
||||
>{{ $t('credits.topUp.contactUs') }}</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:disabled="!isValidAmount || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
{{ $t('credits.topUp.buyCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
{{ $t('credits.topUp.viewPricing') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover } from 'primevue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd } from '@/base/credits/comfyCredits'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
||||
|
||||
interface CreditOption {
|
||||
credits: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const { isInsufficientCredits = false } = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
}>()
|
||||
|
||||
const { formattedRenewalDate } = useSubscription()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
|
||||
const selectedCredits = ref<number | null>(null)
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
const MAX_AMOUNT = 10000
|
||||
|
||||
// State
|
||||
const selectedPreset = ref<number | null>(50)
|
||||
const payAmount = ref(50)
|
||||
const showCeilingWarning = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const popover = ref()
|
||||
// Computed
|
||||
const pricingUrl = computed(() =>
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||
)
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
const creditsModel = computed({
|
||||
get: () => usdToCredits(payAmount.value),
|
||||
set: (newCredits: number) => {
|
||||
payAmount.value = Math.round(creditsToUsd(newCredits))
|
||||
selectedPreset.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isValidAmount = computed(
|
||||
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
|
||||
)
|
||||
|
||||
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
|
||||
|
||||
// Utility functions
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
const creditOptions: CreditOption[] = [
|
||||
{
|
||||
credits: 1055, // $5.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 30 })
|
||||
},
|
||||
{
|
||||
credits: 2110, // $10.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 60 })
|
||||
},
|
||||
{
|
||||
credits: 4220, // $20.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 120 })
|
||||
},
|
||||
{
|
||||
credits: 10550, // $50.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 301 })
|
||||
}
|
||||
]
|
||||
// Step amount functions
|
||||
function getStepAmount(currentAmount: number): number {
|
||||
if (currentAmount < 100) return 5
|
||||
if (currentAmount < 1000) return 50
|
||||
return 100
|
||||
}
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!selectedCredits.value) return
|
||||
function getCreditsStepAmount(currentCredits: number): number {
|
||||
const usdAmount = creditsToUsd(currentCredits)
|
||||
return usdToCredits(getStepAmount(usdAmount))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handlePayAmountChange(value: number) {
|
||||
payAmount.value = value
|
||||
selectedPreset.value = null
|
||||
showCeilingWarning.value = false
|
||||
}
|
||||
|
||||
function handlePresetClick(amount: number) {
|
||||
showCeilingWarning.value = false
|
||||
payAmount.value = amount
|
||||
selectedPreset.value = amount
|
||||
}
|
||||
|
||||
function handleClose(clearTracking = true) {
|
||||
if (clearTracking) {
|
||||
clearTopupTracking()
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
// Prevent double-clicks
|
||||
if (loading.value || !isValidAmount.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const usdAmount = creditsToUsd(selectedCredits.value)
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
|
||||
await authActions.purchaseCredits(usdAmount)
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
await authActions.purchaseCredits(payAmount.value)
|
||||
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog(
|
||||
isSubscriptionEnabled() ? 'subscription' : 'credits'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
|
||||
293
src/components/honeyToast/HoneyToast.stories.ts
Normal file
293
src/components/honeyToast/HoneyToast.stories.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import HoneyToast from './HoneyToast.vue'
|
||||
|
||||
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesTotal: 1000000,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0,
|
||||
status: 'created',
|
||||
lastUpdate: Date.now(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Meta<typeof HoneyToast> = {
|
||||
title: 'Toast/HoneyToast',
|
||||
component: HoneyToast,
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(false)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'running',
|
||||
progress: 0.45
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-3',
|
||||
assetName: 'vae-decoder.safetensors',
|
||||
status: 'created'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
|
||||
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">1 of 3</span>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Expanded: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(true)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'running',
|
||||
progress: 0.45
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-3',
|
||||
assetName: 'vae-decoder.safetensors',
|
||||
status: 'created'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
|
||||
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">1 of 3</span>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(false)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesDownloaded: 1000000,
|
||||
progress: 1,
|
||||
status: 'completed'
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetId: 'asset-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
bytesTotal: 500000,
|
||||
bytesDownloaded: 500000,
|
||||
progress: 1,
|
||||
status: 'completed'
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
|
||||
<span class="font-bold text-base-foreground">All downloads completed</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
<Button variant="muted-textonly" size="icon">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithError: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast, Button, ProgressToastItem },
|
||||
setup() {
|
||||
const isExpanded = ref(true)
|
||||
const jobs = [
|
||||
createMockJob({
|
||||
taskId: 'task-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
status: 'failed',
|
||||
progress: 0.23
|
||||
}),
|
||||
createMockJob({
|
||||
taskId: 'task-2',
|
||||
assetName: 'lora-style.safetensors',
|
||||
status: 'completed',
|
||||
progress: 1
|
||||
})
|
||||
]
|
||||
return { isExpanded, cn, jobs }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible="true">
|
||||
<template #default>
|
||||
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
|
||||
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
|
||||
</div>
|
||||
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
|
||||
<span class="font-bold text-base-foreground">1 download failed</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
|
||||
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
|
||||
</Button>
|
||||
<Button variant="muted-textonly" size="icon">
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const Hidden: Story = {
|
||||
render: () => ({
|
||||
components: { HoneyToast },
|
||||
template: `
|
||||
<div>
|
||||
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
|
||||
|
||||
<HoneyToast :visible="false">
|
||||
<template #default>
|
||||
<div class="px-4 py-4">Content</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="h-12 px-4">Footer</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
137
src/components/honeyToast/HoneyToast.test.ts
Normal file
137
src/components/honeyToast/HoneyToast.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import HoneyToast from './HoneyToast.vue'
|
||||
|
||||
describe('HoneyToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountComponent(
|
||||
props: { visible: boolean; expanded?: boolean } = { visible: true }
|
||||
): VueWrapper {
|
||||
return mount(HoneyToast, {
|
||||
props,
|
||||
slots: {
|
||||
default: (slotProps: { isExpanded: boolean }) =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-testid': 'content' },
|
||||
slotProps.isExpanded ? 'expanded' : 'collapsed'
|
||||
),
|
||||
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'toggle-btn',
|
||||
onClick: slotProps.toggle
|
||||
},
|
||||
slotProps.isExpanded ? 'Collapse' : 'Expand'
|
||||
)
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
}
|
||||
|
||||
it('renders when visible is true', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast).toBeTruthy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not render when visible is false', async () => {
|
||||
const wrapper = mountComponent({ visible: false })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast).toBeFalsy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes is-expanded=false to slots by default', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const content = document.body.querySelector('[data-testid="content"]')
|
||||
expect(content?.textContent).toBe('collapsed')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies collapsed max-height class when collapsed', async () => {
|
||||
const wrapper = mountComponent({ visible: true, expanded: false })
|
||||
await nextTick()
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('has aria-live="polite" for accessibility', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast?.getAttribute('aria-live')).toBe('polite')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('supports v-model:expanded with reactive parent state', async () => {
|
||||
const TestWrapper = defineComponent({
|
||||
components: { HoneyToast },
|
||||
setup() {
|
||||
const expanded = ref(false)
|
||||
return { expanded }
|
||||
},
|
||||
template: `
|
||||
<HoneyToast :visible="true" v-model:expanded="expanded">
|
||||
<template #default="slotProps">
|
||||
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
|
||||
</template>
|
||||
<template #footer="slotProps">
|
||||
<button data-testid="toggle-btn" @click="slotProps.toggle">
|
||||
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
`
|
||||
})
|
||||
|
||||
const wrapper = mount(TestWrapper, { attachTo: document.body })
|
||||
await nextTick()
|
||||
|
||||
const content = document.body.querySelector('[data-testid="content"]')
|
||||
expect(content?.textContent).toBe('collapsed')
|
||||
|
||||
const toggleBtn = document.body.querySelector(
|
||||
'[data-testid="toggle-btn"]'
|
||||
) as HTMLButtonElement
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
|
||||
|
||||
toggleBtn?.click()
|
||||
await nextTick()
|
||||
|
||||
expect(content?.textContent).toBe('expanded')
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
||||
|
||||
const expandableArea = document.body.querySelector(
|
||||
'[role="status"] > div:first-child'
|
||||
)
|
||||
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
46
src/components/honeyToast/HoneyToast.vue
Normal file
46
src/components/honeyToast/HoneyToast.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { visible } = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const isExpanded = defineModel<boolean>('expanded', { default: false })
|
||||
|
||||
function toggle() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="translate-y-full opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'overflow-hidden transition-all duration-300',
|
||||
isExpanded ? 'max-h-[400px]' : 'max-h-0'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot :is-expanded />
|
||||
</div>
|
||||
|
||||
<slot name="footer" :is-expanded :toggle />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -24,7 +24,6 @@
|
||||
v-model:light-config="lightConfig"
|
||||
:is-splat-model="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:has-skeleton="hasSkeleton"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
@@ -34,9 +33,6 @@
|
||||
v-model:playing="playing"
|
||||
v-model:selected-speed="selectedSpeed"
|
||||
v-model:selected-animation="selectedAnimation"
|
||||
v-model:animation-progress="animationProgress"
|
||||
v-model:animation-duration="animationDuration"
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -117,15 +113,12 @@ const {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
animationDuration,
|
||||
loading,
|
||||
loadingMessage,
|
||||
|
||||
@@ -137,7 +130,6 @@ const {
|
||||
handleStopRecording,
|
||||
handleExportRecording,
|
||||
handleClearRecording,
|
||||
handleSeek,
|
||||
handleBackgroundImageUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
|
||||
@@ -58,10 +58,8 @@
|
||||
v-if="showModelControls"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:up-direction="modelConfig!.upDirection"
|
||||
v-model:show-skeleton="modelConfig!.showSkeleton"
|
||||
:hide-material-mode="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:has-skeleton="hasSkeleton"
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
@@ -101,14 +99,9 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
isSplatModel = false,
|
||||
isPlyModel = false,
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
const { isSplatModel = false, isPlyModel = false } = defineProps<{
|
||||
isSplatModel?: boolean
|
||||
isPlyModel?: boolean
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -15,16 +15,6 @@
|
||||
@dragleave.stop="handleDragLeave"
|
||||
@drop.prevent.stop="handleDrop"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="viewer.animations.value && viewer.animations.value.length > 0"
|
||||
v-model:animations="viewer.animations.value"
|
||||
v-model:playing="viewer.playing.value"
|
||||
v-model:selected-speed="viewer.selectedSpeed.value"
|
||||
v-model:selected-animation="viewer.selectedAnimation.value"
|
||||
v-model:animation-progress="viewer.animationProgress.value"
|
||||
v-model:animation-duration="viewer.animationDuration.value"
|
||||
@seek="viewer.handleSeek"
|
||||
/>
|
||||
<div
|
||||
v-if="isDragging"
|
||||
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@@ -95,7 +85,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
|
||||
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
|
||||
|
||||
@@ -1,64 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="animations && animations.length > 0"
|
||||
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full flex-col items-center gap-2 pt-2"
|
||||
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full items-center justify-center gap-2 pt-2"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('g.playPause')"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'pi',
|
||||
playing ? 'pi-pause' : 'pi-play',
|
||||
'text-lg text-white'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
<Button
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('g.playPause')"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<i
|
||||
:class="['pi', playing ? 'pi-pause' : 'pi-play', 'text-lg text-white']"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
/>
|
||||
|
||||
<div class="flex w-full max-w-xs items-center gap-2 px-4">
|
||||
<Slider
|
||||
:model-value="[animationProgress]"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="0.1"
|
||||
class="flex-1"
|
||||
@update:model-value="handleSliderChange"
|
||||
/>
|
||||
<span class="min-w-16 text-xs text-white">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
@@ -66,16 +44,6 @@ const animations = defineModel<Animation[]>('animations')
|
||||
const playing = defineModel<boolean>('playing')
|
||||
const selectedSpeed = defineModel<number>('selectedSpeed')
|
||||
const selectedAnimation = defineModel<number>('selectedAnimation')
|
||||
const animationProgress = defineModel<number>('animationProgress', {
|
||||
default: 0
|
||||
})
|
||||
const animationDuration = defineModel<number>('animationDuration', {
|
||||
default: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
seek: [progress: number]
|
||||
}>()
|
||||
|
||||
const speedOptions = [
|
||||
{ name: '0.1x', value: 0.1 },
|
||||
@@ -85,25 +53,7 @@ const speedOptions = [
|
||||
{ name: '2x', value: 2 }
|
||||
]
|
||||
|
||||
const currentTime = computed(() => {
|
||||
if (!animationDuration.value) return 0
|
||||
return (animationProgress.value / 100) * animationDuration.value
|
||||
})
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = (seconds % 60).toFixed(1)
|
||||
return mins > 0 ? `${mins}:${secs.padStart(4, '0')}` : `${secs}s`
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const togglePlay = () => {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function handleSliderChange(value: number[] | undefined) {
|
||||
if (!value) return
|
||||
const progress = value[0]
|
||||
animationProgress.value = progress
|
||||
emit('seek', progress)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -70,22 +70,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSkeleton">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.showSkeleton'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="cn('rounded-full', showSkeleton && 'bg-blue-500')"
|
||||
:aria-label="t('load3d.showSkeleton')"
|
||||
@click="showSkeleton = !showSkeleton"
|
||||
>
|
||||
<i class="pi pi-sitemap text-lg text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -100,19 +84,13 @@ import type {
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
hideMaterialMode = false,
|
||||
isPlyModel = false,
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
const showSkeleton = defineModel<boolean>('showSkeleton')
|
||||
|
||||
const showUpDirection = ref(false)
|
||||
const showMaterialMode = ref(false)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3 class="text-center text-[15px] font-sans text-descrip-text mt-2.5">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.brushSettings') }}
|
||||
</h3>
|
||||
|
||||
@@ -8,211 +10,120 @@
|
||||
{{ t('maskEditor.resetToDefault') }}
|
||||
</button>
|
||||
|
||||
<!-- Brush Shape -->
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-descrip-text">
|
||||
{{ t('maskEditor.brushShape') }}
|
||||
</span>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.brushShape')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeCircle hover:bg-comfy-menu-bg"
|
||||
:class="
|
||||
cn(
|
||||
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-comfy-menu-bg"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Arc
|
||||
? 'bg-[var(--p-button-text-primary-color)] active'
|
||||
: 'bg-transparent'
|
||||
)
|
||||
"
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Arc)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeSquare hover:bg-comfy-menu-bg"
|
||||
:class="
|
||||
cn(
|
||||
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-comfy-menu-bg"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Rect
|
||||
? 'bg-[var(--p-button-text-primary-color)] active'
|
||||
: 'bg-transparent'
|
||||
)
|
||||
"
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Rect)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-descrip-text">
|
||||
{{ t('maskEditor.colorSelector') }}
|
||||
</span>
|
||||
<input
|
||||
ref="colorInputRef"
|
||||
v-model="store.rgbColor"
|
||||
type="color"
|
||||
class="h-10 rounded-md cursor-pointer"
|
||||
/>
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.colorSelector')
|
||||
}}</span>
|
||||
<input type="color" :value="store.rgbColor" @input="onColorChange" />
|
||||
</div>
|
||||
|
||||
<!-- Thickness -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-left text-xs font-sans text-descrip-text">
|
||||
{{ t('maskEditor.thickness') }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="brushSize"
|
||||
type="number"
|
||||
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
|
||||
:min="1"
|
||||
:max="250"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
v-model="brushSize"
|
||||
class="flex-1"
|
||||
label=""
|
||||
:min="1"
|
||||
:max="250"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
:label="t('maskEditor.thickness')"
|
||||
:min="1"
|
||||
:max="500"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.size"
|
||||
@update:model-value="onThicknessChange"
|
||||
/>
|
||||
|
||||
<!-- Opacity -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-left text-xs font-sans text-descrip-text">
|
||||
{{ t('maskEditor.opacity') }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="brushOpacity"
|
||||
type="number"
|
||||
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
v-model="brushOpacity"
|
||||
class="flex-1"
|
||||
label=""
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
:label="t('maskEditor.opacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.opacity"
|
||||
@update:model-value="onOpacityChange"
|
||||
/>
|
||||
|
||||
<!-- Hardness -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-left text-xs font-sans text-descrip-text">
|
||||
{{ t('maskEditor.hardness') }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="brushHardness"
|
||||
type="number"
|
||||
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
v-model="brushHardness"
|
||||
class="flex-1"
|
||||
label=""
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
:label="t('maskEditor.hardness')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.hardness"
|
||||
@update:model-value="onHardnessChange"
|
||||
/>
|
||||
|
||||
<!-- Step Size -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-left text-xs font-sans text-descrip-text">
|
||||
{{ t('maskEditor.stepSize') }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="brushStepSize"
|
||||
type="number"
|
||||
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
v-model="brushStepSize"
|
||||
class="flex-1"
|
||||
label=""
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
:label="$t('maskEditor.stepSize')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.stepSize"
|
||||
@update:model-value="onStepSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const colorInputRef = ref<HTMLInputElement>()
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-32 rounded-[10px] border border-p-form-field-border-color text-input-text font-sans transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
|
||||
'h-7.5 w-32 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
|
||||
|
||||
/* Computed properties that use store setters for validation */
|
||||
const brushSize = computed({
|
||||
get: () => store.brushSettings.size,
|
||||
set: (value: number) => store.setBrushSize(value)
|
||||
})
|
||||
|
||||
const brushOpacity = computed({
|
||||
get: () => store.brushSettings.opacity,
|
||||
set: (value: number) => store.setBrushOpacity(value)
|
||||
})
|
||||
|
||||
const brushHardness = computed({
|
||||
get: () => store.brushSettings.hardness,
|
||||
set: (value: number) => store.setBrushHardness(value)
|
||||
})
|
||||
|
||||
const brushStepSize = computed({
|
||||
get: () => store.brushSettings.stepSize,
|
||||
set: (value: number) => store.setBrushStepSize(value)
|
||||
})
|
||||
|
||||
/* Brush shape */
|
||||
const setBrushShape = (shape: BrushShape) => {
|
||||
store.brushSettings.type = shape
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
const onColorChange = (event: Event) => {
|
||||
store.rgbColor = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onThicknessChange = (value: number) => {
|
||||
store.setBrushSize(value)
|
||||
}
|
||||
|
||||
const onOpacityChange = (value: number) => {
|
||||
store.setBrushOpacity(value)
|
||||
}
|
||||
|
||||
const onHardnessChange = (value: number) => {
|
||||
store.setBrushHardness(value)
|
||||
}
|
||||
|
||||
const onStepSizeChange = (value: number) => {
|
||||
store.setBrushStepSize(value)
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
store.resetBrushToDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (colorInputRef.value) {
|
||||
store.colorInput = colorInputRef.value
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
store.colorInput = null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -202,7 +202,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
store.canvasHistory.clearStates()
|
||||
|
||||
store.resetState()
|
||||
dataStore.reset()
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export const extractExecutionError = (
|
||||
}
|
||||
}
|
||||
|
||||
export type UseJobErrorReportingOptions = {
|
||||
type UseJobErrorReportingOptions = {
|
||||
taskForJob: ComputedRef<TaskItemImpl | null>
|
||||
copyToClipboard: CopyHandler
|
||||
dialog: JobErrorDialogService
|
||||
|
||||
@@ -47,36 +47,11 @@
|
||||
<MediaAssetFilterBar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="handleClearQueue"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
<Divider type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
@@ -189,7 +164,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import Divider from 'primevue/divider'
|
||||
import { Divider } from 'primevue'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -212,26 +187,17 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
@@ -260,19 +226,6 @@ const formattedExecutionTime = computed(() => {
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
})
|
||||
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsCount = computed(
|
||||
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobs',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
@@ -537,10 +490,6 @@ const handleDeleteSelected = async () => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (
|
||||
activeTab.value === 'output' &&
|
||||
|
||||
95
src/components/toast/ProgressToastItem.stories.ts
Normal file
95
src/components/toast/ProgressToastItem.stories.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
import ProgressToastItem from './ProgressToastItem.vue'
|
||||
|
||||
const meta: Meta<typeof ProgressToastItem> = {
|
||||
title: 'Toast/ProgressToastItem',
|
||||
component: ProgressToastItem,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div class="w-[400px] bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
assetId: 'asset-1',
|
||||
assetName: 'model-v1.safetensors',
|
||||
bytesTotal: 1000000,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0,
|
||||
status: 'created',
|
||||
lastUpdate: Date.now(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'created',
|
||||
assetName: 'sd-xl-base-1.0.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Running: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.45,
|
||||
assetName: 'lora-detail-enhancer.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningAlmostComplete: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.92,
|
||||
assetName: 'vae-ft-mse-840000.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'completed',
|
||||
progress: 1,
|
||||
assetName: 'controlnet-canny.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'failed',
|
||||
progress: 0.23,
|
||||
assetName: 'unreachable-model.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const LongFileName: Story = {
|
||||
args: {
|
||||
job: createMockJob({
|
||||
status: 'running',
|
||||
progress: 0.67,
|
||||
assetName:
|
||||
'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors'
|
||||
})
|
||||
}
|
||||
}
|
||||
65
src/components/toast/ProgressToastItem.vue
Normal file
65
src/components/toast/ProgressToastItem.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { job } = defineProps<{
|
||||
job: AssetDownload
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const progressPercent = computed(() => Math.round(job.progress * 100))
|
||||
const isCompleted = computed(() => job.status === 'completed')
|
||||
const isFailed = computed(() => job.status === 'failed')
|
||||
const isRunning = computed(() => job.status === 'running')
|
||||
const isPending = computed(() => job.status === 'created')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
|
||||
isCompleted && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm text-base-foreground">{{
|
||||
job.assetName
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<template v-if="isFailed">
|
||||
<i
|
||||
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
|
||||
/>
|
||||
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isCompleted">
|
||||
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isRunning">
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
|
||||
/>
|
||||
<span class="text-xs text-base-foreground">
|
||||
{{ progressPercent }}%
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isPending">
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ t('progressToast.pending') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonVariants = cva({
|
||||
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
@@ -18,8 +18,7 @@ export const buttonVariants = cva({
|
||||
'muted-textonly':
|
||||
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
|
||||
'destructive-textonly':
|
||||
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
|
||||
'text-destructive-background bg-transparent hover:bg-destructive-background/10'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -45,8 +44,7 @@ const variants = [
|
||||
'destructive',
|
||||
'textonly',
|
||||
'muted-textonly',
|
||||
'destructive-textonly',
|
||||
'overlay-white'
|
||||
'destructive-textonly'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
|
||||
ButtonVariants['size']
|
||||
|
||||
181
src/components/ui/stepper/FormattedNumberStepper.vue
Normal file
181
src/components/ui/stepper/FormattedNumberStepper.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<label
|
||||
:for="inputId"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 cursor-text items-center rounded-lg bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover focus-within:ring-1 focus-within:ring-secondary-foreground',
|
||||
disabled && 'opacity-50 pointer-events-none'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-l-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
|
||||
:disabled="disabled || modelValue <= min"
|
||||
:aria-label="$t('g.decrement')"
|
||||
@click="handleStep(-1)"
|
||||
>
|
||||
<i class="icon-[lucide--minus] size-4" />
|
||||
</button>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center gap-0.5 overflow-hidden"
|
||||
>
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:style="{ width: `${inputWidth}ch` }"
|
||||
class="min-w-0 rounded border-none bg-transparent text-center text-base-foreground font-medium text-lg focus-visible:outline-none"
|
||||
:disabled="disabled"
|
||||
@input="handleInputChange"
|
||||
@blur="handleInputBlur"
|
||||
@focus="handleInputFocus"
|
||||
/>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-r-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
|
||||
:disabled="disabled || modelValue >= max"
|
||||
:aria-label="$t('g.increment')"
|
||||
@click="handleStep(1)"
|
||||
>
|
||||
<i class="icon-[lucide--plus] size-4" />
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min = 0,
|
||||
max = Infinity,
|
||||
step = 1,
|
||||
formatOptions = { useGrouping: true },
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number | ((value: number) => number)
|
||||
formatOptions?: Intl.NumberFormatOptions
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'max-reached': []
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const inputId = useId()
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const inputValue = ref(formatNumber(modelValue.value))
|
||||
|
||||
const inputWidth = computed(() =>
|
||||
Math.min(Math.max(inputValue.value.length, 1) + 0.5, 9)
|
||||
)
|
||||
|
||||
watch(modelValue, (newValue) => {
|
||||
if (document.activeElement !== inputRef.value) {
|
||||
inputValue.value = formatNumber(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US', formatOptions)
|
||||
}
|
||||
|
||||
function parseFormattedNumber(str: string): number {
|
||||
const cleaned = str.replace(/[^0-9]/g, '')
|
||||
return cleaned === '' ? 0 : parseInt(cleaned, 10)
|
||||
}
|
||||
|
||||
function clamp(value: number, minVal: number, maxVal: number): number {
|
||||
return Math.min(Math.max(value, minVal), maxVal)
|
||||
}
|
||||
|
||||
function formatWithCursor(
|
||||
value: string,
|
||||
cursorPos: number
|
||||
): { formatted: string; newCursor: number } {
|
||||
const num = parseFormattedNumber(value)
|
||||
const formatted = formatNumber(num)
|
||||
|
||||
const digitsBeforeCursor = value
|
||||
.slice(0, cursorPos)
|
||||
.replace(/[^0-9]/g, '').length
|
||||
|
||||
let digitCount = 0
|
||||
let newCursor = 0
|
||||
for (let i = 0; i < formatted.length; i++) {
|
||||
if (/[0-9]/.test(formatted[i])) {
|
||||
digitCount++
|
||||
}
|
||||
if (digitCount >= digitsBeforeCursor) {
|
||||
newCursor = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (digitCount < digitsBeforeCursor) {
|
||||
newCursor = formatted.length
|
||||
}
|
||||
|
||||
return { formatted, newCursor }
|
||||
}
|
||||
|
||||
function getStepAmount(): number {
|
||||
return typeof step === 'function' ? step(modelValue.value) : step
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const raw = input.value
|
||||
const cursorPos = input.selectionStart ?? raw.length
|
||||
const num = parseFormattedNumber(raw)
|
||||
|
||||
const clamped = Math.min(num, max)
|
||||
const wasClamped = num > max
|
||||
|
||||
if (wasClamped) {
|
||||
emit('max-reached')
|
||||
}
|
||||
|
||||
modelValue.value = clamped
|
||||
|
||||
const { formatted, newCursor } = formatWithCursor(
|
||||
wasClamped ? formatNumber(clamped) : raw,
|
||||
wasClamped ? formatNumber(clamped).length : cursorPos
|
||||
)
|
||||
inputValue.value = formatted
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.value?.setSelectionRange(newCursor, newCursor)
|
||||
})
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
const clamped = clamp(modelValue.value, min, max)
|
||||
modelValue.value = clamped
|
||||
inputValue.value = formatNumber(clamped)
|
||||
}
|
||||
|
||||
function handleInputFocus(e: FocusEvent) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const len = input.value.length
|
||||
input.setSelectionRange(len, len)
|
||||
}
|
||||
|
||||
function handleStep(direction: 1 | -1) {
|
||||
const stepAmount = getStepAmount()
|
||||
const newValue = clamp(modelValue.value + stepAmount * direction, min, max)
|
||||
modelValue.value = newValue
|
||||
inputValue.value = formatNumber(newValue)
|
||||
}
|
||||
</script>
|
||||
@@ -13,10 +13,11 @@
|
||||
<i class="icon-[lucide--panel-right] text-sm" />
|
||||
</Button>
|
||||
<Button
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
|
||||
size="lg"
|
||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<i class="pi pi-times text-sm"></i>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
<div class="flex h-full w-full">
|
||||
<Transition name="slide-panel">
|
||||
@@ -80,7 +81,9 @@
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto">
|
||||
<div
|
||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<div v-if="icon" class="py-0.5">
|
||||
<div v-if="icon" class="pt-0.5">
|
||||
<NavIcon :icon="icon" />
|
||||
</div>
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface VueNodeData {
|
||||
hasErrors?: boolean
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
resizable?: boolean
|
||||
shape?: number
|
||||
subgraphId?: string | null
|
||||
titleMode?: TitleMode
|
||||
@@ -244,17 +245,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
const updatedWidgets = currentData.widgets?.map((widget) => {
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
|
||||
})
|
||||
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
widgets: updatedWidgets,
|
||||
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
|
||||
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
|
||||
})
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
@@ -325,6 +319,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
resizable: node.resizable,
|
||||
shape: node.shape
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1823,28 +1823,35 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
// Google Veo video generation
|
||||
if (model.includes('veo-2.0')) {
|
||||
return formatCreditsLabel(0.5, { suffix: '/second' })
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-flash')) {
|
||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-pro')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gemini-3-pro-preview')) {
|
||||
return formatCreditsListLabel([0.002, 0.012], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
@@ -1899,51 +1906,75 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
|
||||
if (model.includes('o4-mini')) {
|
||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('o1-pro')) {
|
||||
return formatCreditsListLabel([0.15, 0.6], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('o1')) {
|
||||
return formatCreditsListLabel([0.015, 0.06], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('o3-mini')) {
|
||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('o3')) {
|
||||
return formatCreditsListLabel([0.01, 0.04], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-4o')) {
|
||||
return formatCreditsListLabel([0.0025, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1-nano')) {
|
||||
return formatCreditsListLabel([0.0001, 0.0004], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1-mini')) {
|
||||
return formatCreditsListLabel([0.0004, 0.0016], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1')) {
|
||||
return formatCreditsListLabel([0.002, 0.008], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-5-nano')) {
|
||||
return formatCreditsListLabel([0.00005, 0.0004], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-5-mini')) {
|
||||
return formatCreditsListLabel([0.00025, 0.002], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
} else if (model.includes('gpt-5')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens'
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
})
|
||||
}
|
||||
return 'Token-based'
|
||||
@@ -2101,6 +2132,267 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
LtxvApiImageToVideo: {
|
||||
displayPrice: ltxvPricingCalculator
|
||||
},
|
||||
WanReferenceVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const sizeWidget = node.widgets?.find(
|
||||
(w) => w.name === 'size'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !sizeWidget) {
|
||||
return formatCreditsRangeLabel(0.7, 1.5, {
|
||||
note: '(varies with size & duration)'
|
||||
})
|
||||
}
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const sizeStr = String(sizeWidget.value).toLowerCase()
|
||||
|
||||
const rate = sizeStr.includes('1080p') ? 0.15 : 0.1
|
||||
const inputMin = 2 * rate
|
||||
const inputMax = 5 * rate
|
||||
const outputPrice = seconds * rate
|
||||
|
||||
const minTotal = inputMin + outputPrice
|
||||
const maxTotal = inputMax + outputPrice
|
||||
|
||||
return formatCreditsRangeLabel(minTotal, maxTotal)
|
||||
}
|
||||
},
|
||||
Vidu2TextToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||
note: '(varies with duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
|
||||
// Text-to-Video uses Q2 model only
|
||||
// 720P: Starts at $0.075, +$0.025/sec
|
||||
// 1080P: Starts at $0.10, +$0.05/sec
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (resolution.includes('1080')) {
|
||||
basePrice = 0.1
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P default
|
||||
basePrice = 0.075
|
||||
pricePerSecond = 0.025
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||
note: '(varies with duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2ImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (model.includes('q2-pro-fast')) {
|
||||
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||
basePrice = is1080p ? 0.08 : 0.04
|
||||
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||
} else if (model.includes('q2-pro')) {
|
||||
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||
basePrice = is1080p ? 0.275 : 0.075
|
||||
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||
} else if (model.includes('q2-turbo')) {
|
||||
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||
if (is1080p) {
|
||||
basePrice = 0.175
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||
if (duration <= 1) {
|
||||
return formatCreditsLabel(0.04)
|
||||
}
|
||||
if (duration <= 2) {
|
||||
return formatCreditsLabel(0.05)
|
||||
}
|
||||
const cost = 0.05 + 0.05 * (duration - 2)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
} else {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2ReferenceVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
const audioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'audio'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget) {
|
||||
return formatCreditsRangeLabel(0.125, 1.5, {
|
||||
note: '(varies with duration, resolution & audio)'
|
||||
})
|
||||
}
|
||||
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget?.value ?? '').toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
// Check if audio is enabled (adds $0.75)
|
||||
const audioValue = audioWidget?.value
|
||||
const hasAudio =
|
||||
audioValue !== undefined &&
|
||||
audioValue !== null &&
|
||||
String(audioValue).toLowerCase() !== 'false' &&
|
||||
String(audioValue).toLowerCase() !== 'none' &&
|
||||
audioValue !== ''
|
||||
|
||||
// Reference-to-Video uses Q2 model
|
||||
// 720P: Starts at $0.125, +$0.025/sec
|
||||
// 1080P: Starts at $0.375, +$0.05/sec
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (is1080p) {
|
||||
basePrice = 0.375
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P default
|
||||
basePrice = 0.125
|
||||
pricePerSecond = 0.025
|
||||
}
|
||||
|
||||
let cost = basePrice
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
cost = basePrice + pricePerSecond * (duration - 1)
|
||||
}
|
||||
|
||||
// Audio adds $0.75 on top
|
||||
if (hasAudio) {
|
||||
cost += 0.075
|
||||
}
|
||||
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2StartEndToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (model.includes('q2-pro-fast')) {
|
||||
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||
basePrice = is1080p ? 0.08 : 0.04
|
||||
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||
} else if (model.includes('q2-pro')) {
|
||||
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||
basePrice = is1080p ? 0.275 : 0.075
|
||||
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||
} else if (model.includes('q2-turbo')) {
|
||||
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||
if (is1080p) {
|
||||
basePrice = 0.175
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||
if (!Number.isFinite(duration) || duration <= 1) {
|
||||
return formatCreditsLabel(0.04)
|
||||
}
|
||||
if (duration <= 2) {
|
||||
return formatCreditsLabel(0.05)
|
||||
}
|
||||
const cost = 0.05 + 0.05 * (duration - 2)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
} else {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsLabel(basePrice)
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2254,8 +2546,13 @@ export const useNodePricing = () => {
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution'],
|
||||
WanReferenceVideoApi: ['duration', 'size'],
|
||||
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
|
||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
|
||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution'],
|
||||
Vidu2TextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'],
|
||||
Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -39,11 +39,7 @@ import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import {
|
||||
useQueueSettingsStore,
|
||||
useQueueStore,
|
||||
useQueueUIStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
@@ -67,6 +63,7 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -78,7 +75,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const executionStore = useExecutionStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
@@ -86,14 +82,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
function isQueuePanelV2Enabled() {
|
||||
return settingStore.get('Comfy.Queue.QPOV2')
|
||||
}
|
||||
|
||||
async function toggleQueuePanelV2() {
|
||||
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled())
|
||||
}
|
||||
|
||||
const moveSelectedNodes = (
|
||||
positionUpdater: (pos: Point, gridSize: number) => Point
|
||||
) => {
|
||||
@@ -435,18 +423,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
},
|
||||
active: () => useSettingStore().get('Comfy.Minimap.Visible')
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Queue.ToggleOverlay',
|
||||
icon: 'pi pi-history',
|
||||
label: () => t('queue.toggleJobHistory'),
|
||||
menubarLabel: () => t('queue.jobHistory'),
|
||||
versionAdded: '1.37.0',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
useQueueUIStore().toggleOverlay()
|
||||
},
|
||||
active: () => useQueueUIStore().isOverlayExpanded
|
||||
},
|
||||
{
|
||||
id: 'Comfy.QueuePrompt',
|
||||
icon: 'pi pi-play',
|
||||
@@ -905,15 +881,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ToggleManagerProgressDialog',
|
||||
icon: 'pi pi-spinner',
|
||||
label: 'Toggle the Custom Nodes Manager Progress Bar',
|
||||
versionAdded: '1.13.9',
|
||||
function: () => {
|
||||
dialogService.toggleManagerProgressDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.User.OpenSignInDialog',
|
||||
icon: 'pi pi-user',
|
||||
@@ -1199,12 +1166,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleQPOV2',
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Queue Panel V2',
|
||||
function: toggleQueuePanelV2
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
|
||||
@@ -40,12 +40,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const modelConfig = ref<ModelConfig>({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
materialMode: 'original'
|
||||
})
|
||||
|
||||
const hasSkeleton = ref(false)
|
||||
|
||||
const cameraConfig = ref<CameraConfig>({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
@@ -63,8 +60,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const playing = ref(false)
|
||||
const selectedSpeed = ref(1)
|
||||
const selectedAnimation = ref(0)
|
||||
const animationProgress = ref(0)
|
||||
const animationDuration = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
const isPreview = ref(false)
|
||||
@@ -276,7 +271,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Model Config'] = newValue
|
||||
load3d.setUpDirection(newValue.upDirection)
|
||||
load3d.setMaterialMode(newValue.materialMode)
|
||||
load3d.setShowSkeleton(newValue.showSkeleton)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -363,13 +357,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeek = (progress: number) => {
|
||||
if (load3d && animationDuration.value > 0) {
|
||||
const time = (progress / 100) * animationDuration.value
|
||||
load3d.setAnimationTime(time)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
if (!file) {
|
||||
sceneConfig.value.backgroundImage = ''
|
||||
@@ -507,12 +494,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
},
|
||||
skeletonVisibilityChange: (value: boolean) => {
|
||||
modelConfig.value.showSkeleton = value
|
||||
},
|
||||
exportLoadingStart: (message: string) => {
|
||||
loadingMessage.value = message || t('load3d.exportingModel')
|
||||
@@ -533,14 +514,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
animationListChange: (newValue: AnimationItem[]) => {
|
||||
animations.value = newValue
|
||||
},
|
||||
animationProgressChange: (data: {
|
||||
progress: number
|
||||
currentTime: number
|
||||
duration: number
|
||||
}) => {
|
||||
animationProgress.value = data.progress
|
||||
animationDuration.value = data.duration
|
||||
},
|
||||
cameraChanged: (cameraState: CameraState) => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
if (rawNode) {
|
||||
@@ -594,15 +567,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
animationDuration,
|
||||
loading,
|
||||
loadingMessage,
|
||||
|
||||
@@ -615,7 +585,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleStopRecording,
|
||||
handleExportRecording,
|
||||
handleClearRecording,
|
||||
handleSeek,
|
||||
handleBackgroundImageUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref, toRaw, watch } from 'vue'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
CameraState,
|
||||
CameraType,
|
||||
@@ -50,14 +49,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
|
||||
// Animation state
|
||||
const animations = ref<AnimationItem[]>([])
|
||||
const playing = ref(false)
|
||||
const selectedSpeed = ref(1)
|
||||
const selectedAnimation = ref(0)
|
||||
const animationProgress = ref(0)
|
||||
const animationDuration = ref(0)
|
||||
|
||||
let load3d: Load3d | null = null
|
||||
let sourceLoad3d: Load3d | null = null
|
||||
|
||||
@@ -183,61 +174,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Animation watches
|
||||
watch(playing, (newValue) => {
|
||||
if (load3d) {
|
||||
load3d.toggleAnimation(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedSpeed, (newValue) => {
|
||||
if (load3d && newValue) {
|
||||
load3d.setAnimationSpeed(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedAnimation, (newValue) => {
|
||||
if (load3d && newValue !== undefined) {
|
||||
load3d.updateSelectedAnimation(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
const handleSeek = (progress: number) => {
|
||||
if (load3d && animationDuration.value > 0) {
|
||||
const time = (progress / 100) * animationDuration.value
|
||||
load3d.setAnimationTime(time)
|
||||
}
|
||||
}
|
||||
|
||||
const setupAnimationEvents = () => {
|
||||
if (!load3d) return
|
||||
|
||||
load3d.addEventListener(
|
||||
'animationListChange',
|
||||
(newValue: AnimationItem[]) => {
|
||||
animations.value = newValue
|
||||
}
|
||||
)
|
||||
|
||||
load3d.addEventListener(
|
||||
'animationProgressChange',
|
||||
(data: { progress: number; currentTime: number; duration: number }) => {
|
||||
animationProgress.value = data.progress
|
||||
animationDuration.value = data.duration
|
||||
}
|
||||
)
|
||||
|
||||
// Initialize animation list if animations already exist
|
||||
if (load3d.hasAnimations()) {
|
||||
const clips = load3d.animationManager.animationClips
|
||||
animations.value = clips.map((clip, index) => ({
|
||||
name: clip.name || `Animation ${index + 1}`,
|
||||
index
|
||||
}))
|
||||
animationDuration.value = load3d.getAnimationDuration()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize viewer in node mode (with source Load3d)
|
||||
*/
|
||||
@@ -334,8 +270,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
}
|
||||
|
||||
setupAnimationEvents()
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d viewer:', error)
|
||||
useToastStore().addAlert(
|
||||
@@ -376,8 +310,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
|
||||
isPreview.value = true
|
||||
|
||||
setupAnimationEvents()
|
||||
} catch (error) {
|
||||
console.error('Error initializing standalone 3D viewer:', error)
|
||||
useToastStore().addAlert('Failed to load 3D model')
|
||||
@@ -595,14 +527,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
|
||||
// Animation state
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
animationDuration,
|
||||
|
||||
// Methods
|
||||
initializeViewer,
|
||||
initializeStandaloneViewer,
|
||||
@@ -615,7 +539,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
refreshViewport,
|
||||
handleBackgroundImageUpdate,
|
||||
handleModelDrop,
|
||||
handleSeek,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,12 @@ import type { Ref } from 'vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const rankingStore = useTemplateRankingStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedModels = ref<string[]>(
|
||||
@@ -27,8 +25,6 @@ export function useTemplateFiltering(
|
||||
)
|
||||
const sortBy = ref<
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
@@ -82,31 +78,13 @@ export function useTemplateFiltering(
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||
|
||||
// Store Fuse search results with scores for use in sorting
|
||||
const fuseSearchResults = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
return null
|
||||
}
|
||||
return fuse.value.search(debouncedSearchQuery.value)
|
||||
})
|
||||
|
||||
// Map of template name to search score (lower is better in Fuse, 0 = perfect match)
|
||||
const searchScoreMap = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
if (fuseSearchResults.value) {
|
||||
fuseSearchResults.value.forEach((result) => {
|
||||
// Store the score (0 = perfect match, 1 = worst match)
|
||||
map.set(result.item.name, result.score ?? 1)
|
||||
})
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!fuseSearchResults.value) {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
return templatesArray.value
|
||||
}
|
||||
return fuseSearchResults.value.map((result) => result.item)
|
||||
|
||||
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||
return results.map((result) => result.item)
|
||||
})
|
||||
|
||||
const filteredByModels = computed(() => {
|
||||
@@ -173,77 +151,10 @@ export function useTemplateFiltering(
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
watch(
|
||||
filteredByRunsOn,
|
||||
(templates) => {
|
||||
rankingStore.largestUsageScore = Math.max(
|
||||
...templates.map((t) => t.usage || 0)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Helper to get search relevance score (higher is better, 0-1 range)
|
||||
// Fuse returns scores where 0 = perfect match, 1 = worst match
|
||||
// We invert it so higher = better for combining with other scores
|
||||
const getSearchRelevance = (template: TemplateInfo): number => {
|
||||
const fuseScore = searchScoreMap.value.get(template.name)
|
||||
if (fuseScore === undefined) return 0 // Not in search results or no search
|
||||
return 1 - fuseScore // Invert: 0 (worst) -> 1 (best)
|
||||
}
|
||||
|
||||
const hasActiveSearch = computed(
|
||||
() => debouncedSearchQuery.value.trim() !== ''
|
||||
)
|
||||
|
||||
const sortedTemplates = computed(() => {
|
||||
const templates = [...filteredByRunsOn.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'recommended':
|
||||
// When searching, heavily weight search relevance
|
||||
// Formula with search: searchRelevance × 0.6 + (usage × 0.5 + internal × 0.3 + freshness × 0.2) × 0.4
|
||||
// Formula without search: usage × 0.5 + internal × 0.3 + freshness × 0.2
|
||||
return templates.sort((a, b) => {
|
||||
const baseScoreA = rankingStore.computeDefaultScore(
|
||||
a.date,
|
||||
a.searchRank,
|
||||
a.usage
|
||||
)
|
||||
const baseScoreB = rankingStore.computeDefaultScore(
|
||||
b.date,
|
||||
b.searchRank,
|
||||
b.usage
|
||||
)
|
||||
|
||||
if (hasActiveSearch.value) {
|
||||
const searchA = getSearchRelevance(a)
|
||||
const searchB = getSearchRelevance(b)
|
||||
const finalA = searchA * 0.6 + baseScoreA * 0.4
|
||||
const finalB = searchB * 0.6 + baseScoreB * 0.4
|
||||
return finalB - finalA
|
||||
}
|
||||
|
||||
return baseScoreB - baseScoreA
|
||||
})
|
||||
case 'popular':
|
||||
// When searching, include search relevance
|
||||
// Formula with search: searchRelevance × 0.5 + (usage × 0.9 + freshness × 0.1) × 0.5
|
||||
// Formula without search: usage × 0.9 + freshness × 0.1
|
||||
return templates.sort((a, b) => {
|
||||
const baseScoreA = rankingStore.computePopularScore(a.date, a.usage)
|
||||
const baseScoreB = rankingStore.computePopularScore(b.date, b.usage)
|
||||
|
||||
if (hasActiveSearch.value) {
|
||||
const searchA = getSearchRelevance(a)
|
||||
const searchB = getSearchRelevance(b)
|
||||
const finalA = searchA * 0.5 + baseScoreA * 0.5
|
||||
const finalB = searchB * 0.5 + baseScoreB * 0.5
|
||||
return finalB - finalA
|
||||
}
|
||||
|
||||
return baseScoreB - baseScoreA
|
||||
})
|
||||
case 'alphabetical':
|
||||
return templates.sort((a, b) => {
|
||||
const nameA = a.title || a.name || ''
|
||||
@@ -262,12 +173,6 @@ export function useTemplateFiltering(
|
||||
const vramB = getVramMetric(b)
|
||||
|
||||
if (vramA === vramB) {
|
||||
// Use search relevance as tiebreaker when searching
|
||||
if (hasActiveSearch.value) {
|
||||
const searchA = getSearchRelevance(a)
|
||||
const searchB = getSearchRelevance(b)
|
||||
if (searchA !== searchB) return searchB - searchA
|
||||
}
|
||||
const nameA = a.title || a.name || ''
|
||||
const nameB = b.title || b.name || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
@@ -279,25 +184,17 @@ export function useTemplateFiltering(
|
||||
return vramA - vramB
|
||||
})
|
||||
case 'model-size-low-to-high':
|
||||
return templates.sort((a, b) => {
|
||||
return templates.sort((a: any, b: any) => {
|
||||
const sizeA =
|
||||
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
|
||||
const sizeB =
|
||||
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
|
||||
if (sizeA === sizeB) {
|
||||
// Use search relevance as tiebreaker when searching
|
||||
if (hasActiveSearch.value) {
|
||||
const searchA = getSearchRelevance(a)
|
||||
const searchB = getSearchRelevance(b)
|
||||
if (searchA !== searchB) return searchB - searchA
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if (sizeA === sizeB) return 0
|
||||
return sizeA - sizeB
|
||||
})
|
||||
case 'default':
|
||||
default:
|
||||
// 'default' preserves Fuse's search order (already sorted by relevance)
|
||||
// Keep original order (default order)
|
||||
return templates
|
||||
}
|
||||
})
|
||||
@@ -309,7 +206,7 @@ export function useTemplateFiltering(
|
||||
selectedModels.value = []
|
||||
selectedUseCases.value = []
|
||||
selectedRunsOn.value = []
|
||||
sortBy.value = 'default'
|
||||
sortBy.value = 'newest'
|
||||
}
|
||||
|
||||
const removeModelFilter = (model: string) => {
|
||||
|
||||
@@ -196,7 +196,8 @@ export class GroupNodeConfig {
|
||||
primitiveToWidget: {}
|
||||
nodeInputs: {}
|
||||
outputVisibility: any[]
|
||||
nodeDef: (ComfyNodeDef & { [GROUP]: GroupNodeConfig }) | undefined
|
||||
// @ts-expect-error fixme ts strict error
|
||||
nodeDef: ComfyNodeDef
|
||||
// @ts-expect-error fixme ts strict error
|
||||
inputs: any[]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -230,7 +231,8 @@ export class GroupNodeConfig {
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false, // This is a lie (to satisfy the interface)
|
||||
// @ts-expect-error Unused, doesn't exist
|
||||
output_is_hidden: [],
|
||||
name: source + SEPARATOR + this.name,
|
||||
display_name: this.name,
|
||||
category: 'group nodes' + (SEPARATOR + source),
|
||||
@@ -259,7 +261,6 @@ export class GroupNodeConfig {
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.#convertedToProcess = null
|
||||
if (!this.nodeDef) return
|
||||
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
|
||||
useNodeDefStore().addNodeDef(this.nodeDef)
|
||||
}
|
||||
|
||||
@@ -125,13 +125,6 @@ export class AnimationManager implements AnimationManagerInterface {
|
||||
}
|
||||
|
||||
this.animationActions = [action]
|
||||
|
||||
// Emit initial progress to set duration
|
||||
this.eventManager.emitEvent('animationProgressChange', {
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
duration: clip.duration
|
||||
})
|
||||
}
|
||||
|
||||
toggleAnimation(play?: boolean): void {
|
||||
@@ -157,58 +150,8 @@ export class AnimationManager implements AnimationManagerInterface {
|
||||
update(delta: number): void {
|
||||
if (this.currentAnimation && this.isAnimationPlaying) {
|
||||
this.currentAnimation.update(delta)
|
||||
|
||||
if (this.animationActions.length > 0) {
|
||||
const action = this.animationActions[0]
|
||||
const clip = action.getClip()
|
||||
const progress = (action.time / clip.duration) * 100
|
||||
this.eventManager.emitEvent('animationProgressChange', {
|
||||
progress,
|
||||
currentTime: action.time,
|
||||
duration: clip.duration
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAnimationTime(): number {
|
||||
if (this.animationActions.length === 0) return 0
|
||||
return this.animationActions[0].time
|
||||
}
|
||||
|
||||
getAnimationDuration(): number {
|
||||
if (this.animationActions.length === 0) return 0
|
||||
return this.animationActions[0].getClip().duration
|
||||
}
|
||||
|
||||
setAnimationTime(time: number): void {
|
||||
if (this.animationActions.length === 0) return
|
||||
const duration = this.getAnimationDuration()
|
||||
const clampedTime = Math.max(0, Math.min(time, duration))
|
||||
|
||||
// Temporarily unpause to allow time update, then restore
|
||||
const wasPaused = this.animationActions.map((action) => action.paused)
|
||||
this.animationActions.forEach((action) => {
|
||||
action.paused = false
|
||||
action.time = clampedTime
|
||||
})
|
||||
|
||||
if (this.currentAnimation) {
|
||||
this.currentAnimation.setTime(clampedTime)
|
||||
this.currentAnimation.update(0)
|
||||
}
|
||||
|
||||
// Restore paused state
|
||||
this.animationActions.forEach((action, i) => {
|
||||
action.paused = wasPaused[i]
|
||||
})
|
||||
|
||||
this.eventManager.emitEvent('animationProgressChange', {
|
||||
progress: (clampedTime / duration) * 100,
|
||||
currentTime: clampedTime,
|
||||
duration
|
||||
})
|
||||
}
|
||||
|
||||
reset(): void {}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export class CameraManager implements CameraManagerInterface {
|
||||
orthographicCamera: THREE.OrthographicCamera
|
||||
activeCamera: THREE.Camera
|
||||
|
||||
// @ts-expect-error unused variable
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
private controls: OrbitControls | null = null
|
||||
@@ -40,9 +42,10 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
|
||||
constructor(
|
||||
_renderer: THREE.WebGLRenderer,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.eventManager = eventManager
|
||||
|
||||
this.perspectiveCamera = new THREE.PerspectiveCamera(
|
||||
|
||||
@@ -156,9 +156,8 @@ class Load3DConfiguration {
|
||||
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
}
|
||||
materialMode: 'original'
|
||||
} as ModelConfig
|
||||
}
|
||||
|
||||
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
||||
|
||||
@@ -727,32 +727,6 @@ class Load3d {
|
||||
return this.animationManager.animationClips.length > 0
|
||||
}
|
||||
|
||||
public hasSkeleton(): boolean {
|
||||
return this.modelManager.hasSkeleton()
|
||||
}
|
||||
|
||||
public setShowSkeleton(show: boolean): void {
|
||||
this.modelManager.setShowSkeleton(show)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getShowSkeleton(): boolean {
|
||||
return this.modelManager.showSkeleton
|
||||
}
|
||||
|
||||
public getAnimationTime(): number {
|
||||
return this.animationManager.getAnimationTime()
|
||||
}
|
||||
|
||||
public getAnimationDuration(): number {
|
||||
return this.animationManager.getAnimationDuration()
|
||||
}
|
||||
|
||||
public setAnimationTime(time: number): void {
|
||||
this.animationManager.setAnimationTime(time)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.contextMenuAbortController) {
|
||||
this.contextMenuAbortController.abort()
|
||||
|
||||
@@ -34,26 +34,9 @@ class Load3dUtils {
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
static readonly MAX_UPLOAD_SIZE_MB = 100
|
||||
|
||||
static async uploadFile(file: File, subfolder: string) {
|
||||
let uploadPath
|
||||
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
if (fileSizeMB > this.MAX_UPLOAD_SIZE_MB) {
|
||||
const message = t('toastMessages.fileTooLarge', {
|
||||
size: fileSizeMB.toFixed(1),
|
||||
maxSize: this.MAX_UPLOAD_SIZE_MB
|
||||
})
|
||||
console.warn(
|
||||
'[Load3D] uploadFile: file too large',
|
||||
fileSizeMB.toFixed(2),
|
||||
'MB'
|
||||
)
|
||||
useToastStore().addAlert(message)
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
@@ -78,7 +61,7 @@ class Load3dUtils {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Load3D] uploadFile: exception', error)
|
||||
console.error('Upload error:', error)
|
||||
useToastStore().addAlert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
|
||||
@@ -3,12 +3,9 @@ import * as THREE from 'three'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
||||
// Use pre-bundled worker module (has all dependencies included)
|
||||
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -25,7 +22,7 @@ import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export class LoaderManager implements LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
objLoader: OBJLoader
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
@@ -44,12 +41,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
this.eventManager = eventManager
|
||||
|
||||
this.gltfLoader = new GLTFLoader()
|
||||
this.objLoader = new OBJLoader2Parallel()
|
||||
// Set worker URL for Vite compatibility
|
||||
this.objLoader.setWorkerUrl(
|
||||
true,
|
||||
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
||||
)
|
||||
this.objLoader = new OBJLoader()
|
||||
this.mtlLoader = new MTLLoader()
|
||||
this.fbxLoader = new FBXLoader()
|
||||
this.stlLoader = new STLLoader()
|
||||
@@ -185,9 +177,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
|
||||
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
||||
materials.preload()
|
||||
const materialsFromMtl =
|
||||
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
||||
this.objLoader.setMaterials(materialsFromMtl)
|
||||
this.objLoader.setMaterials(materials)
|
||||
} catch (e) {
|
||||
console.log(
|
||||
'No MTL file found or error loading it, continuing without materials'
|
||||
@@ -195,10 +185,8 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
|
||||
const objUrl = path + encodeURIComponent(filename)
|
||||
model = await this.objLoader.loadAsync(objUrl)
|
||||
|
||||
this.objLoader.setPath(path)
|
||||
model = await this.objLoader.loadAsync(filename)
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
@@ -209,6 +197,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
this.gltfLoader.setPath(path)
|
||||
|
||||
const gltf = await this.gltfLoader.loadAsync(filename)
|
||||
|
||||
this.modelManager.setOriginalModel(gltf)
|
||||
|
||||
@@ -27,11 +27,13 @@ export class SceneManager implements SceneManagerInterface {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
// @ts-expect-error unused variable
|
||||
private getControls: () => OrbitControls
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
_getControls: () => OrbitControls,
|
||||
getControls: () => OrbitControls,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
@@ -39,6 +41,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
|
||||
this.gridHelper = new THREE.GridHelper(20, 20)
|
||||
this.gridHelper.position.set(0, 0, 0)
|
||||
|
||||
@@ -30,8 +30,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
originalURL: string | null = null
|
||||
appliedTexture: THREE.Texture | null = null
|
||||
textureLoader: THREE.TextureLoader
|
||||
skeletonHelper: THREE.SkeletonHelper | null = null
|
||||
showSkeleton: boolean = false
|
||||
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
@@ -416,69 +414,9 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.appliedTexture = null
|
||||
}
|
||||
|
||||
if (this.skeletonHelper) {
|
||||
this.scene.remove(this.skeletonHelper)
|
||||
this.skeletonHelper.dispose()
|
||||
this.skeletonHelper = null
|
||||
}
|
||||
this.showSkeleton = false
|
||||
|
||||
this.originalMaterials = new WeakMap()
|
||||
}
|
||||
|
||||
hasSkeleton(): boolean {
|
||||
if (!this.currentModel) return false
|
||||
let found = false
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.SkinnedMesh && child.skeleton) {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
setShowSkeleton(show: boolean): void {
|
||||
this.showSkeleton = show
|
||||
|
||||
if (show) {
|
||||
if (!this.skeletonHelper && this.currentModel) {
|
||||
let rootBone: THREE.Bone | null = null
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.Bone && !rootBone) {
|
||||
if (!(child.parent instanceof THREE.Bone)) {
|
||||
rootBone = child
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (rootBone) {
|
||||
this.skeletonHelper = new THREE.SkeletonHelper(rootBone)
|
||||
this.scene.add(this.skeletonHelper)
|
||||
} else {
|
||||
let skinnedMesh: THREE.SkinnedMesh | null = null
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.SkinnedMesh && !skinnedMesh) {
|
||||
skinnedMesh = child
|
||||
}
|
||||
})
|
||||
|
||||
if (skinnedMesh) {
|
||||
this.skeletonHelper = new THREE.SkeletonHelper(skinnedMesh)
|
||||
this.scene.add(this.skeletonHelper)
|
||||
}
|
||||
}
|
||||
} else if (this.skeletonHelper) {
|
||||
this.skeletonHelper.visible = true
|
||||
}
|
||||
} else {
|
||||
if (this.skeletonHelper) {
|
||||
this.skeletonHelper.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('skeletonVisibilityChange', show)
|
||||
}
|
||||
|
||||
addModelToScene(model: THREE.Object3D): void {
|
||||
this.currentModel = model
|
||||
model.name = 'MainModel'
|
||||
|
||||
@@ -14,13 +14,16 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
private getControls: () => OrbitControls
|
||||
private eventManager: EventManagerInterface
|
||||
// @ts-expect-error unused variable
|
||||
private renderer: THREE.WebGLRenderer
|
||||
|
||||
constructor(
|
||||
_renderer: THREE.WebGLRenderer,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
getControls: () => OrbitControls,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
this.eventManager = eventManager
|
||||
|
||||
@@ -4,8 +4,8 @@ import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
||||
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { type OBJLoader2Parallel } from 'wwobjloader2'
|
||||
|
||||
export type MaterialMode =
|
||||
| 'original'
|
||||
@@ -34,7 +34,6 @@ export interface SceneConfig {
|
||||
export interface ModelConfig {
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
showSkeleton: boolean
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
@@ -147,9 +146,6 @@ export interface AnimationManagerInterface extends BaseManager {
|
||||
updateSelectedAnimation(index: number): void
|
||||
toggleAnimation(play?: boolean): void
|
||||
update(delta: number): void
|
||||
getAnimationTime(): number
|
||||
getAnimationDuration(): number
|
||||
setAnimationTime(time: number): void
|
||||
}
|
||||
|
||||
export interface ModelManagerInterface {
|
||||
@@ -180,7 +176,7 @@ export interface ModelManagerInterface {
|
||||
|
||||
export interface LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
objLoader: OBJLoader
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
|
||||
@@ -37,15 +37,6 @@ function isOpened(): boolean {
|
||||
return useDialogStore().isDialogOpen('global-mask-editor')
|
||||
}
|
||||
|
||||
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
|
||||
if (!isOpened()) return
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const oldBrushSize = store.brushSettings.size
|
||||
const newBrushSize = sizeChanger(oldBrushSize)
|
||||
store.setBrushSize(newBrushSize)
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.MaskEditor',
|
||||
settings: [
|
||||
@@ -91,24 +82,13 @@ app.registerExtension({
|
||||
id: 'Comfy.MaskEditor.BrushSize.Increase',
|
||||
icon: 'pi pi-plus-circle',
|
||||
label: 'Increase Brush Size in MaskEditor',
|
||||
function: () => changeBrushSize((old) => _.clamp(old + 2, 1, 250))
|
||||
function: () => changeBrushSize((old) => _.clamp(old + 4, 1, 100))
|
||||
},
|
||||
{
|
||||
id: 'Comfy.MaskEditor.BrushSize.Decrease',
|
||||
icon: 'pi pi-minus-circle',
|
||||
label: 'Decrease Brush Size in MaskEditor',
|
||||
function: () => changeBrushSize((old) => _.clamp(old - 2, 1, 250))
|
||||
},
|
||||
{
|
||||
id: 'Comfy.MaskEditor.ColorPicker',
|
||||
icon: 'pi pi-palette',
|
||||
label: 'Open Color Picker in MaskEditor',
|
||||
function: () => {
|
||||
if (!isOpened()) return
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
store.colorInput?.click()
|
||||
}
|
||||
function: () => changeBrushSize((old) => _.clamp(old - 4, 1, 100))
|
||||
}
|
||||
],
|
||||
init() {
|
||||
@@ -121,3 +101,12 @@ app.registerExtension({
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
|
||||
if (!isOpened()) return
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const oldBrushSize = store.brushSettings.size
|
||||
const newBrushSize = sizeChanger(oldBrushSize)
|
||||
store.setBrushSize(newBrushSize)
|
||||
}
|
||||
|
||||
@@ -969,8 +969,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onGroupAdd(
|
||||
_info: unknown,
|
||||
_entry: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
info: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
entry: unknown,
|
||||
mouse_event: MouseEvent
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
@@ -1018,8 +1020,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onNodeAlign(
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
@@ -1042,8 +1046,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onGroupAlign(
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>
|
||||
): void {
|
||||
@@ -1064,8 +1070,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static createDistributeMenu(
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>
|
||||
): void {
|
||||
@@ -1087,13 +1095,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuAdd(
|
||||
_value: unknown,
|
||||
_options: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
e: MouseEvent,
|
||||
prev_menu?: ContextMenu<string>,
|
||||
callback?: (node: LGraphNode | null) => void
|
||||
): boolean | undefined {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
const { graph } = canvas
|
||||
if (!graph) return
|
||||
|
||||
@@ -1144,7 +1155,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
value: category_path,
|
||||
content: name,
|
||||
has_submenu: true,
|
||||
callback: function (value, _event, _mouseEvent, contextMenu) {
|
||||
callback: function (
|
||||
value,
|
||||
// @ts-expect-error - unused parameter
|
||||
event,
|
||||
// @ts-expect-error - unused parameter
|
||||
mouseEvent,
|
||||
contextMenu
|
||||
) {
|
||||
inner_onMenuAdded(value.value, contextMenu)
|
||||
}
|
||||
})
|
||||
@@ -1163,7 +1181,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
value: node.type,
|
||||
content: node.title,
|
||||
has_submenu: false,
|
||||
callback: function (value, _event, _mouseEvent, contextMenu) {
|
||||
callback: function (
|
||||
value,
|
||||
// @ts-expect-error - unused parameter
|
||||
event,
|
||||
// @ts-expect-error - unused parameter
|
||||
mouseEvent,
|
||||
contextMenu
|
||||
) {
|
||||
if (!canvas.graph) throw new NullGraphError()
|
||||
|
||||
const first_event = contextMenu.getFirstEvent()
|
||||
@@ -1188,7 +1213,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
|
||||
new LiteGraph.ContextMenu(
|
||||
entries,
|
||||
{ event: e, parentMenu: prev_menu },
|
||||
// @ts-expect-error - extra parameter
|
||||
ref_window
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1197,7 +1227,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
/** @param _options Parameter is never used */
|
||||
static showMenuNodeOptionalOutputs(
|
||||
_v: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
v: unknown,
|
||||
/** Unused - immediately overwritten */
|
||||
_options: INodeOutputSlot[],
|
||||
e: MouseEvent,
|
||||
@@ -1281,7 +1312,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** @param value Parameter is never used */
|
||||
static onShowMenuNodeProperties(
|
||||
value: NodeProperty | undefined,
|
||||
_options: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
e: MouseEvent,
|
||||
prev_menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
@@ -1289,6 +1321,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!node || !node.properties) return
|
||||
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
|
||||
const entries: IContextMenuValue<string>[] = []
|
||||
for (const i in node.properties) {
|
||||
@@ -1311,20 +1344,23 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu<string>(entries, {
|
||||
event: e,
|
||||
callback: inner_clicked,
|
||||
parentMenu: prev_menu,
|
||||
node
|
||||
})
|
||||
new LiteGraph.ContextMenu<string>(
|
||||
entries,
|
||||
{
|
||||
event: e,
|
||||
callback: inner_clicked,
|
||||
parentMenu: prev_menu,
|
||||
allow_html: true,
|
||||
node
|
||||
},
|
||||
// @ts-expect-error Unused
|
||||
ref_window
|
||||
)
|
||||
|
||||
function inner_clicked(
|
||||
this: ContextMenu<string>,
|
||||
v?: string | IContextMenuValue<string>
|
||||
) {
|
||||
if (!node || typeof v === 'string' || !v?.value) return
|
||||
function inner_clicked(this: ContextMenuDivElement, v: { value: any }) {
|
||||
if (!node) return
|
||||
|
||||
const rect = this.root.getBoundingClientRect()
|
||||
const rect = this.getBoundingClientRect()
|
||||
canvas.showEditPropertyValue(node, v.value, {
|
||||
position: [rect.left, rect.top]
|
||||
})
|
||||
@@ -1341,10 +1377,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuResizeNode(
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node) return
|
||||
@@ -1371,9 +1411,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// TODO refactor :: this is used fot title but not for properties!
|
||||
static onShowPropertyEditor(
|
||||
item: { property: keyof LGraphNode; type: string },
|
||||
_options: IContextMenuOptions<string>,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions<string>,
|
||||
e: MouseEvent,
|
||||
_menu: ContextMenu<string>,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
const property = item.property || 'title'
|
||||
@@ -1443,10 +1485,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
input.focus()
|
||||
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let dialogCloseTimer: number
|
||||
dialog.addEventListener('mouseleave', function () {
|
||||
if (LiteGraph.dialog_close_on_mouse_leave) {
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -1501,10 +1544,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeCollapse(
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
@@ -1531,10 +1578,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuToggleAdvanced(
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
@@ -1559,8 +1610,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeMode(
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
e: MouseEvent,
|
||||
menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
@@ -1604,7 +1657,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** @param value Parameter is never used */
|
||||
static onMenuNodeColors(
|
||||
value: IContextMenuValue<string | null>,
|
||||
_options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
e: MouseEvent,
|
||||
menu: ContextMenu<string | null>,
|
||||
node: LGraphNode
|
||||
@@ -1665,8 +1719,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeShapes(
|
||||
_value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
_options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
e: MouseEvent,
|
||||
menu?: ContextMenu<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
node?: LGraphNode
|
||||
@@ -3540,16 +3596,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.node_over?.onMouseUp?.(
|
||||
e,
|
||||
[x - this.node_over.pos[0], y - this.node_over.pos[1]],
|
||||
// @ts-expect-error - extra parameter
|
||||
this
|
||||
)
|
||||
this.node_capturing_input?.onMouseUp?.(
|
||||
e,
|
||||
[
|
||||
x - this.node_capturing_input.pos[0],
|
||||
y - this.node_capturing_input.pos[1]
|
||||
],
|
||||
this
|
||||
)
|
||||
this.node_capturing_input?.onMouseUp?.(e, [
|
||||
x - this.node_capturing_input.pos[0],
|
||||
y - this.node_capturing_input.pos[1]
|
||||
])
|
||||
}
|
||||
} else if (e.button === 1) {
|
||||
// middle button
|
||||
@@ -4546,8 +4599,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/**
|
||||
* converts a coordinate from graph coordinates to canvas2D coordinates
|
||||
*/
|
||||
convertOffsetToCanvas(pos: Point, _out?: Point): Point {
|
||||
return this.ds.convertOffsetToCanvas(pos)
|
||||
convertOffsetToCanvas(pos: Point, out: Point): Point {
|
||||
// @ts-expect-error Unused param
|
||||
return this.ds.convertOffsetToCanvas(pos, out)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6090,7 +6144,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/**
|
||||
* draws every group area in the background
|
||||
*/
|
||||
drawGroups(_canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
|
||||
drawGroups(
|
||||
// @ts-expect-error - unused parameter
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D
|
||||
): void {
|
||||
if (!this.graph) return
|
||||
|
||||
const groups = this.graph._groups
|
||||
@@ -6184,7 +6242,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
function inner_clicked(
|
||||
this: LGraphCanvas,
|
||||
v: string,
|
||||
_options: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
e: MouseEvent
|
||||
) {
|
||||
if (!graph) throw new NullGraphError()
|
||||
@@ -6703,12 +6762,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (this.ds.scale > 1) dialog.style.transform = `scale(${this.ds.scale})`
|
||||
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let dialogCloseTimer: number
|
||||
let prevent_timeout = 0
|
||||
LiteGraph.pointerListenerAdd(dialog, 'leave', function () {
|
||||
if (prevent_timeout) return
|
||||
if (LiteGraph.dialog_close_on_mouse_leave) {
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -6897,7 +6957,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (options.hide_on_mouse_leave) {
|
||||
// FIXME: Remove "any" kludge
|
||||
let prevent_timeout: any = false
|
||||
let timeout_close: ReturnType<typeof setTimeout> | null = null
|
||||
let timeout_close: number | null = null
|
||||
LiteGraph.pointerListenerAdd(dialog, 'enter', function () {
|
||||
if (timeout_close) {
|
||||
clearTimeout(timeout_close)
|
||||
@@ -6909,6 +6969,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
const hideDelay = options.hide_on_mouse_leave
|
||||
const delay = typeof hideDelay === 'number' ? hideDelay : 500
|
||||
// @ts-expect-error - setTimeout type
|
||||
timeout_close = setTimeout(dialog.close, delay)
|
||||
})
|
||||
// if filtering, check focus changed to comboboxes and prevent closing
|
||||
@@ -6944,7 +7005,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
that.search_box = dialog
|
||||
|
||||
let first: string | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
let timeout: number | null = null
|
||||
let selected: ChildNode | null = null
|
||||
|
||||
const maybeInput = dialog.querySelector('input')
|
||||
@@ -6978,6 +7039,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (timeout) {
|
||||
clearInterval(timeout)
|
||||
}
|
||||
// @ts-expect-error - setTimeout type
|
||||
timeout = setTimeout(refreshHelper, 10)
|
||||
return
|
||||
}
|
||||
@@ -7252,7 +7314,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
options.show_general_after_typefiltered &&
|
||||
(sIn.value || sOut.value)
|
||||
) {
|
||||
const filtered_extra: string[] = []
|
||||
// FIXME: Undeclared variable again
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra = []
|
||||
for (const i in LiteGraph.registered_node_types) {
|
||||
if (
|
||||
inner_test_filter(i, {
|
||||
@@ -7260,9 +7324,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
outTypeOverride: sOut && sOut.value ? '*' : false
|
||||
})
|
||||
) {
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra.push(i)
|
||||
}
|
||||
}
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
for (const extraItem of filtered_extra) {
|
||||
addResult(extraItem, 'generic_type')
|
||||
if (
|
||||
@@ -7279,11 +7345,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
helper.childNodes.length == 0 &&
|
||||
options.show_general_if_none_on_typefilter
|
||||
) {
|
||||
const filtered_extra: string[] = []
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra = []
|
||||
for (const i in LiteGraph.registered_node_types) {
|
||||
if (inner_test_filter(i, { skipFilter: true }))
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra.push(i)
|
||||
}
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
for (const extraItem of filtered_extra) {
|
||||
addResult(extraItem, 'not_in_filter')
|
||||
if (
|
||||
@@ -7578,12 +7647,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let dialogCloseTimer: number
|
||||
let prevent_timeout = 0
|
||||
dialog.addEventListener('mouseleave', function () {
|
||||
if (prevent_timeout) return
|
||||
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -7617,6 +7687,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
createPanel(title: string, options: ICreatePanelOptions) {
|
||||
options = options || {}
|
||||
|
||||
const ref_window = options.window || window
|
||||
// TODO: any kludge
|
||||
const root: any = document.createElement('div')
|
||||
root.className = 'litegraph dialog'
|
||||
@@ -7794,12 +7865,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
innerChange(propname, v)
|
||||
return false
|
||||
}
|
||||
new LiteGraph.ContextMenu(values, {
|
||||
event,
|
||||
className: 'dark',
|
||||
// @ts-expect-error fixme ts strict error - callback signature mismatch
|
||||
callback: inner_clicked
|
||||
})
|
||||
new LiteGraph.ContextMenu(
|
||||
values,
|
||||
{
|
||||
event,
|
||||
className: 'dark',
|
||||
callback: inner_clicked
|
||||
},
|
||||
// @ts-expect-error ref_window parameter unused in ContextMenu constructor
|
||||
ref_window
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8119,10 +8194,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
{
|
||||
content: 'Properties Panel',
|
||||
callback: function (
|
||||
_item: any,
|
||||
_options: any,
|
||||
_e: any,
|
||||
_menu: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
item: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: any,
|
||||
node: LGraphNode
|
||||
) {
|
||||
LGraphCanvas.active_canvas.showShowNodePanel(node)
|
||||
@@ -8233,6 +8312,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
node: LGraphNode | undefined,
|
||||
event: CanvasPointerEvent
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
|
||||
// TODO: Remove type kludge
|
||||
let menu_info: (IContextMenuValue | string | null)[]
|
||||
const options: IContextMenuOptions = {
|
||||
@@ -8346,7 +8428,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// show menu
|
||||
if (!menu_info) return
|
||||
|
||||
new LiteGraph.ContextMenu(menu_info, options)
|
||||
// @ts-expect-error Remove param ref_window - unused
|
||||
new LiteGraph.ContextMenu(menu_info, options, ref_window)
|
||||
|
||||
const createDialog = (options: IDialogOptions) =>
|
||||
this.createDialog(
|
||||
|
||||
@@ -679,12 +679,7 @@ export class LGraphNode
|
||||
this: LGraphNode,
|
||||
entries: (IContextMenuValue<INodeSlotContextItem> | null)[]
|
||||
): (IContextMenuValue<INodeSlotContextItem> | null)[]
|
||||
onMouseUp?(
|
||||
this: LGraphNode,
|
||||
e: CanvasPointerEvent,
|
||||
pos: Point,
|
||||
canvas: LGraphCanvas
|
||||
): void
|
||||
onMouseUp?(this: LGraphNode, e: CanvasPointerEvent, pos: Point): void
|
||||
onMouseEnter?(this: LGraphNode, e: CanvasPointerEvent): void
|
||||
/** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */
|
||||
onMouseDown?(
|
||||
@@ -2774,7 +2769,8 @@ export class LGraphNode
|
||||
!LiteGraph.allow_multi_output_for_events
|
||||
) {
|
||||
graph.beforeChange()
|
||||
this.disconnectOutput(slot)
|
||||
// @ts-expect-error Unused param
|
||||
this.disconnectOutput(slot, false, { doProcessChange: false })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -338,7 +338,6 @@ export interface INodeFlags {
|
||||
*/
|
||||
export interface IWidgetLocator {
|
||||
name: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface INodeInputSlot extends INodeSlot {
|
||||
|
||||
@@ -111,8 +111,6 @@ export interface ExportedSubgraphInstance extends NodeSubgraphSharedProps {
|
||||
* @see {@link ExportedSubgraph.subgraphs}
|
||||
*/
|
||||
type: UUID
|
||||
/** Custom properties for this subgraph instance */
|
||||
properties?: Dictionary<NodeProperty | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,9 +200,6 @@
|
||||
"Comfy_MaskEditor_BrushSize_Increase": {
|
||||
"label": "Increase Brush Size in MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_ColorPicker": {
|
||||
"label": "Open Color Picker in MaskEditor"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "Open Mask Editor for Selected Node"
|
||||
},
|
||||
@@ -227,9 +224,6 @@
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "Publish Subgraph"
|
||||
},
|
||||
"Comfy_Queue_ToggleOverlay": {
|
||||
"label": "Toggle Job History"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "Queue Prompt"
|
||||
},
|
||||
@@ -266,9 +260,6 @@
|
||||
"Comfy_ToggleLinear": {
|
||||
"label": "toggle linear mode"
|
||||
},
|
||||
"Comfy_ToggleQPOV2": {
|
||||
"label": "Toggle Queue Panel V2"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "Toggle Theme (Dark/Light)"
|
||||
},
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
"downloadVideo": "Download video",
|
||||
"editOrMaskImage": "Edit or mask image",
|
||||
"editImage": "Edit image",
|
||||
"decrement": "Decrement",
|
||||
"deleteImage": "Delete image",
|
||||
"deleteAudioFile": "Delete audio file",
|
||||
"increment": "Increment",
|
||||
"removeImage": "Remove image",
|
||||
"removeVideo": "Remove video",
|
||||
"chart": "Chart",
|
||||
@@ -378,10 +380,6 @@
|
||||
"warningTooltip": "This package may have compatibility issues with your current environment"
|
||||
}
|
||||
},
|
||||
"importFailed": {
|
||||
"title": "Import Failed",
|
||||
"copyError": "Copy Error"
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "Help Fix This"
|
||||
},
|
||||
@@ -513,12 +511,10 @@
|
||||
"title": "Choose your hardware setup",
|
||||
"recommended": "RECOMMENDED",
|
||||
"nvidiaSubtitle": "NVIDIA CUDA",
|
||||
"amdSubtitle": "AMD ROCm™",
|
||||
"cpuSubtitle": "CPU Mode",
|
||||
"manualSubtitle": "Manual Setup",
|
||||
"appleMetalDescription": "Leverages your Mac's GPU for faster speed and a better overall experience",
|
||||
"nvidiaDescription": "Use your NVIDIA GPU with CUDA acceleration for the best performance.",
|
||||
"amdDescription": "Use your AMD GPU with ROCm™ acceleration for the best performance.",
|
||||
"cpuDescription": "Use CPU mode for compatibility when GPU acceleration is not available",
|
||||
"manualDescription": "Configure ComfyUI manually for advanced setups or unsupported hardware"
|
||||
},
|
||||
@@ -725,8 +721,6 @@
|
||||
"colonPercent": ": {percent}",
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"viewList": "List view",
|
||||
"viewGrid": "Grid view",
|
||||
"running": "running",
|
||||
"preview": "Preview",
|
||||
"interruptAll": "Interrupt all running jobs",
|
||||
@@ -742,7 +736,6 @@
|
||||
"filterCurrentWorkflow": "Current workflow",
|
||||
"sortJobs": "Sort jobs",
|
||||
"sortBy": "Sort by",
|
||||
"activeJobs": "{count} active job | {count} active jobs",
|
||||
"activeJobsSuffix": "active jobs",
|
||||
"jobQueue": "Job Queue",
|
||||
"expandCollapsedQueue": "Expand job queue",
|
||||
@@ -881,7 +874,7 @@
|
||||
"noResultsHint": "Try adjusting your search or filters",
|
||||
"allTemplates": "All Templates",
|
||||
"modelFilter": "Model Filter",
|
||||
"useCaseFilter": "Tasks",
|
||||
"useCaseFilter": "Use Case",
|
||||
"licenseFilter": "License",
|
||||
"modelsSelected": "{count} Models",
|
||||
"useCasesSelected": "{count} Use Cases",
|
||||
@@ -890,7 +883,6 @@
|
||||
"resultsCount": "Showing {count} of {total} templates",
|
||||
"sort": {
|
||||
"recommended": "Recommended",
|
||||
"popular": "Popular",
|
||||
"alphabetical": "A → Z",
|
||||
"newest": "Newest",
|
||||
"searchPlaceholder": "Search...",
|
||||
@@ -1046,8 +1038,6 @@
|
||||
"copyErrorMessage": "Copy error message",
|
||||
"reportError": "Report error"
|
||||
},
|
||||
"toggleJobHistory": "Toggle Job History",
|
||||
"jobHistory": "Job History",
|
||||
"jobList": {
|
||||
"undated": "Undated",
|
||||
"sortMostRecent": "Most recent",
|
||||
@@ -1150,7 +1140,6 @@
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
|
||||
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
|
||||
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
|
||||
"Open Color Picker in MaskEditor": "Open Color Picker in MaskEditor",
|
||||
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
|
||||
"Unload Models": "Unload Models",
|
||||
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
|
||||
@@ -1159,7 +1148,6 @@
|
||||
"Manager": "Manager",
|
||||
"Open": "Open",
|
||||
"Publish": "Publish",
|
||||
"Job History": "Job History",
|
||||
"Queue Prompt": "Queue Prompt",
|
||||
"Queue Prompt (Front)": "Queue Prompt (Front)",
|
||||
"Queue Selected Output Nodes": "Queue Selected Output Nodes",
|
||||
@@ -1172,7 +1160,6 @@
|
||||
"Canvas Performance": "Canvas Performance",
|
||||
"Help Center": "Help Center",
|
||||
"toggle linear mode": "toggle linear mode",
|
||||
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
|
||||
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
|
||||
"Undo": "Undo",
|
||||
"Open Sign In Dialog": "Open Sign In Dialog",
|
||||
@@ -1406,8 +1393,6 @@
|
||||
"conditioning": "conditioning",
|
||||
"loaders": "loaders",
|
||||
"guiders": "guiders",
|
||||
"latent": "latent",
|
||||
"mask": "mask",
|
||||
"api node": "api node",
|
||||
"video": "video",
|
||||
"ByteDance": "ByteDance",
|
||||
@@ -1424,16 +1409,17 @@
|
||||
"kandinsky5": "kandinsky5",
|
||||
"hooks": "hooks",
|
||||
"combine": "combine",
|
||||
"logic": "logic",
|
||||
"cond single": "cond single",
|
||||
"context": "context",
|
||||
"controlnet": "controlnet",
|
||||
"inpaint": "inpaint",
|
||||
"scheduling": "scheduling",
|
||||
"create": "create",
|
||||
"mask": "mask",
|
||||
"deprecated": "deprecated",
|
||||
"debug": "debug",
|
||||
"model": "model",
|
||||
"latent": "latent",
|
||||
"3d": "3d",
|
||||
"ltxv": "ltxv",
|
||||
"qwen": "qwen",
|
||||
@@ -1502,9 +1488,6 @@
|
||||
"CLIP_VISION": "CLIP_VISION",
|
||||
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
|
||||
"COMBO": "COMBO",
|
||||
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
|
||||
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
|
||||
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
|
||||
"CONDITIONING": "CONDITIONING",
|
||||
"CONTROL_NET": "CONTROL_NET",
|
||||
"FLOAT": "FLOAT",
|
||||
@@ -1647,7 +1630,6 @@
|
||||
"loadingModel": "Loading 3D Model...",
|
||||
"upDirection": "Up Direction",
|
||||
"materialMode": "Material Mode",
|
||||
"showSkeleton": "Show Skeleton",
|
||||
"scene": "Scene",
|
||||
"model": "Model",
|
||||
"camera": "Camera",
|
||||
@@ -1715,7 +1697,6 @@
|
||||
"pleaseSelectNodesToGroup": "Please select the nodes (or other groups) to create a group for",
|
||||
"emptyCanvas": "Empty canvas",
|
||||
"fileUploadFailed": "File upload failed",
|
||||
"fileTooLarge": "File too large ({size} MB). Maximum supported size is {maxSize} MB",
|
||||
"unableToGetModelFilePath": "Unable to get model file path",
|
||||
"couldNotDetermineFileType": "Could not determine file type",
|
||||
"errorLoadingModel": "Error loading model",
|
||||
@@ -1918,12 +1899,27 @@
|
||||
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
|
||||
"creditsDescription": "Credits are used to run workflows or partner nodes.",
|
||||
"howManyCredits": "How many credits would you like to add?",
|
||||
"videosEstimate": "~{count} videos",
|
||||
"usdAmount": "${amount}",
|
||||
"videosEstimate": "~{count} videos*",
|
||||
"templateNote": "*Generated with Wan Fun Control template",
|
||||
"buy": "Buy",
|
||||
"purchaseError": "Purchase Failed",
|
||||
"purchaseErrorDetail": "Failed to purchase credits: {error}",
|
||||
"unknownError": "An unknown error occurred"
|
||||
"unknownError": "An unknown error occurred",
|
||||
"viewPricing": "View pricing details",
|
||||
"youPay": "Amount (USD)",
|
||||
"youGet": "Credits",
|
||||
"buyCredits": "Continue to payment",
|
||||
"minimumPurchase": "${amount} minimum ({credits} credits)",
|
||||
"maximumAmount": "${amount} max.",
|
||||
"minRequired": "{credits} credits minimum",
|
||||
"maxAllowed": "{credits} credits maximum.",
|
||||
"creditsPerDollar": "credits per dollar",
|
||||
"amountToPayLabel": "Amount to pay in dollars",
|
||||
"creditsToReceiveLabel": "Credits to receive",
|
||||
"selectAmount": "Select amount",
|
||||
"needMore": "Need more?",
|
||||
"contactUs": "Contact us"
|
||||
},
|
||||
"creditsAvailable": "Credits available",
|
||||
"refreshes": "Refreshes {date}",
|
||||
@@ -1960,9 +1956,9 @@
|
||||
"monthlyBonusDescription": "Monthly credit bonus",
|
||||
"prepaidDescription": "Pre-paid credits",
|
||||
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
|
||||
"creditsRemainingThisMonth": "Credits remaining this month",
|
||||
"creditsRemainingThisYear": "Credits remaining this year",
|
||||
"creditsYouveAdded": "Credits you've added",
|
||||
"creditsRemainingThisMonth": "Included (Refills {date})",
|
||||
"creditsRemainingThisYear": "Included (Refills {date})",
|
||||
"creditsYouveAdded": "Additional",
|
||||
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
|
||||
"viewMoreDetailsPlans": "View more details about plans & pricing",
|
||||
"nextBillingCycle": "next billing cycle",
|
||||
@@ -2017,15 +2013,14 @@
|
||||
"subscribeTo": "Subscribe to {plan}",
|
||||
"monthlyCreditsLabel": "Monthly credits",
|
||||
"yearlyCreditsLabel": "Total yearly credits",
|
||||
"maxDurationLabel": "Max duration of each workflow run",
|
||||
"maxDurationLabel": "Max run duration",
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan 2.2 Image-to-Video template",
|
||||
"videoEstimateHelp": "More details on this template",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan 2.2 Image-to-Video template using default settings (5 seconds, 640x640, 16fps, 4-step sampling).",
|
||||
"videoEstimateTryTemplate": "Try this template",
|
||||
"videoTemplateBasedCredits": "Videos generated with Wan 2.2 Image to Video",
|
||||
"videoEstimateLabel": "Number of 5s videos generated with Wan Fun Control template",
|
||||
"videoEstimateHelp": "What is this?",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan Fun Control template for 5-second videos.",
|
||||
"videoEstimateTryTemplate": "Try the Wan Fun Control template →",
|
||||
"upgradePlan": "Upgrade Plan",
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
@@ -2284,6 +2279,7 @@
|
||||
"filterBy": "Filter by",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"importAnother": "Import Another",
|
||||
"genericLinkPlaceholder": "Paste link here",
|
||||
"jobId": "Job ID",
|
||||
"loadingModels": "Loading {type}...",
|
||||
@@ -2377,8 +2373,6 @@
|
||||
"actions": {
|
||||
"inspect": "Inspect asset",
|
||||
"more": "More options",
|
||||
"zoom": "Zoom in",
|
||||
"moreOptions": "More options",
|
||||
"seeMoreOutputs": "See more outputs",
|
||||
"addToWorkflow": "Add to current workflow",
|
||||
"download": "Download",
|
||||
@@ -2480,5 +2474,21 @@
|
||||
"help": {
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
},
|
||||
"progressToast": {
|
||||
"importingModels": "Importing Models",
|
||||
"downloadingModel": "Downloading model...",
|
||||
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
|
||||
"allDownloadsCompleted": "All downloads completed",
|
||||
"noImportsInQueue": "No {filter} in queue",
|
||||
"failed": "Failed",
|
||||
"finished": "Finished",
|
||||
"pending": "Pending",
|
||||
"progressCount": "{completed} of {total}",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,45 +267,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BatchImagesNode": {
|
||||
"display_name": "Batch Images",
|
||||
"inputs": {
|
||||
"images": {
|
||||
"name": "images"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"BatchLatentsNode": {
|
||||
"display_name": "Batch Latents",
|
||||
"inputs": {
|
||||
"latents": {
|
||||
"name": "latents"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"BatchMasksNode": {
|
||||
"display_name": "Batch Masks",
|
||||
"inputs": {
|
||||
"masks": {
|
||||
"name": "masks"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"BetaSamplingScheduler": {
|
||||
"display_name": "BetaSamplingScheduler",
|
||||
"inputs": {
|
||||
@@ -558,7 +519,7 @@
|
||||
}
|
||||
},
|
||||
"ByteDanceSeedreamNode": {
|
||||
"display_name": "ByteDance Seedream 4.5",
|
||||
"display_name": "ByteDance Seedream 4",
|
||||
"description": "Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
@@ -1286,26 +1247,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ComfySwitchNode": {
|
||||
"display_name": "Switch",
|
||||
"inputs": {
|
||||
"switch": {
|
||||
"name": "switch"
|
||||
},
|
||||
"on_false": {
|
||||
"name": "on_false"
|
||||
},
|
||||
"on_true": {
|
||||
"name": "on_true"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "output",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConditioningAverage": {
|
||||
"display_name": "ConditioningAverage",
|
||||
"inputs": {
|
||||
@@ -2015,20 +1956,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CustomCombo": {
|
||||
"display_name": "Custom Combo",
|
||||
"inputs": {
|
||||
"choice": {
|
||||
"name": "choice"
|
||||
},
|
||||
"option0": {}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"DiffControlNetLoader": {
|
||||
"display_name": "Load ControlNet Model (diff)",
|
||||
"inputs": {
|
||||
@@ -4849,9 +4776,6 @@
|
||||
"reference_images": {
|
||||
"name": "reference_images",
|
||||
"tooltip": "Up to 4 additional reference images."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4884,9 +4808,6 @@
|
||||
"reference_images": {
|
||||
"name": "reference_images",
|
||||
"tooltip": "Up to 6 additional reference images."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4943,9 +4864,6 @@
|
||||
"reference_images": {
|
||||
"name": "reference_images",
|
||||
"tooltip": "Up to 7 reference images."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4970,9 +4888,6 @@
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -5008,9 +4923,6 @@
|
||||
"reference_images": {
|
||||
"name": "reference_images",
|
||||
"tooltip": "Up to 4 additional reference images."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -6079,23 +5991,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXAVTextEncoderLoader": {
|
||||
"display_name": "LTXV Audio Text Encoder Loader",
|
||||
"description": "[Recipes]\n\nltxav: gemma 3 12B",
|
||||
"inputs": {
|
||||
"text_encoder": {
|
||||
"name": "text_encoder"
|
||||
},
|
||||
"ckpt_name": {
|
||||
"name": "ckpt_name"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVAddGuide": {
|
||||
"display_name": "LTXVAddGuide",
|
||||
"inputs": {
|
||||
@@ -6202,76 +6097,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVAudioVAEDecode": {
|
||||
"display_name": "LTXV Audio VAE Decode",
|
||||
"inputs": {
|
||||
"samples": {
|
||||
"name": "samples",
|
||||
"tooltip": "The latent to be decoded."
|
||||
},
|
||||
"audio_vae": {
|
||||
"name": "audio_vae",
|
||||
"tooltip": "The Audio VAE model used for decoding the latent."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "Audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVAudioVAEEncode": {
|
||||
"display_name": "LTXV Audio VAE Encode",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "The audio to be encoded."
|
||||
},
|
||||
"audio_vae": {
|
||||
"name": "audio_vae",
|
||||
"tooltip": "The Audio VAE model to use for encoding."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "Audio Latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVAudioVAELoader": {
|
||||
"display_name": "LTXV Audio VAE Loader",
|
||||
"inputs": {
|
||||
"ckpt_name": {
|
||||
"name": "ckpt_name",
|
||||
"tooltip": "Audio VAE checkpoint to load."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "Audio VAE",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVConcatAVLatent": {
|
||||
"display_name": "LTXVConcatAVLatent",
|
||||
"inputs": {
|
||||
"video_latent": {
|
||||
"name": "video_latent"
|
||||
},
|
||||
"audio_latent": {
|
||||
"name": "audio_latent"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVConditioning": {
|
||||
"display_name": "LTXVConditioning",
|
||||
"inputs": {
|
||||
@@ -6324,33 +6149,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVEmptyLatentAudio": {
|
||||
"display_name": "LTXV Empty Latent Audio",
|
||||
"inputs": {
|
||||
"frames_number": {
|
||||
"name": "frames_number",
|
||||
"tooltip": "Number of frames."
|
||||
},
|
||||
"frame_rate": {
|
||||
"name": "frame_rate",
|
||||
"tooltip": "Number of frames per second."
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "batch_size",
|
||||
"tooltip": "The number of latent audio samples in the batch."
|
||||
},
|
||||
"audio_vae": {
|
||||
"name": "audio_vae",
|
||||
"tooltip": "The Audio VAE model to get configuration from."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "Latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVImgToVideo": {
|
||||
"display_name": "LTXVImgToVideo",
|
||||
"inputs": {
|
||||
@@ -6397,47 +6195,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVImgToVideoInplace": {
|
||||
"display_name": "LTXVImgToVideoInplace",
|
||||
"inputs": {
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"latent": {
|
||||
"name": "latent"
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength"
|
||||
},
|
||||
"bypass": {
|
||||
"name": "bypass",
|
||||
"tooltip": "Bypass the conditioning."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVLatentUpsampler": {
|
||||
"display_name": "LTXVLatentUpsampler",
|
||||
"inputs": {
|
||||
"samples": {
|
||||
"name": "samples"
|
||||
},
|
||||
"upscale_model": {
|
||||
"name": "upscale_model"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVPreprocess": {
|
||||
"display_name": "LTXVPreprocess",
|
||||
"inputs": {
|
||||
@@ -6486,25 +6243,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVSeparateAVLatent": {
|
||||
"display_name": "LTXVSeparateAVLatent",
|
||||
"description": "LTXV Separate AV Latent",
|
||||
"inputs": {
|
||||
"av_latent": {
|
||||
"name": "av_latent"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "video_latent",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "audio_latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaConceptsNode": {
|
||||
"display_name": "Luma Concepts",
|
||||
"description": "Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.",
|
||||
@@ -6720,7 +6458,7 @@
|
||||
}
|
||||
},
|
||||
"Mahiro": {
|
||||
"display_name": "Mahiro CFG",
|
||||
"display_name": "Mahiro is so cute that she deserves a better guidance function!! (。・ω・。)",
|
||||
"description": "Modify the guidance to scale more on the 'direction' of the positive prompt rather than the difference between the negative prompt.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
@@ -10894,29 +10632,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResizeImageMaskNode": {
|
||||
"display_name": "Resize Image/Mask",
|
||||
"inputs": {
|
||||
"input": {
|
||||
"name": "input"
|
||||
},
|
||||
"resize_type": {
|
||||
"name": "resize_type"
|
||||
},
|
||||
"scale_method": {
|
||||
"name": "scale_method"
|
||||
},
|
||||
"resize_type_multiplier": {
|
||||
"name": "multiplier"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "resized",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResizeImagesByLongerEdge": {
|
||||
"display_name": "Resize Images by Longer Edge",
|
||||
"inputs": {
|
||||
|
||||
@@ -940,9 +940,6 @@
|
||||
"inbox": "收件箱",
|
||||
"star": "星星"
|
||||
},
|
||||
"imageCompare": {
|
||||
"noImages": "没有可以对比的图像"
|
||||
},
|
||||
"install": {
|
||||
"appDataLocationTooltip": "ComfyUI 的应用数据目录。存储:\n- 日志\n- 服务器配置",
|
||||
"appPathLocationTooltip": "ComfyUI 的应用资产目录。存储 ComfyUI 代码和资产",
|
||||
@@ -2380,7 +2377,6 @@
|
||||
"updateFrontend": "更新前端"
|
||||
},
|
||||
"vueNodesBanner": {
|
||||
"desc": "– 更灵活的工作流,强力的新组件,为拓展性而生",
|
||||
"title": "介绍 Nodes 2.0",
|
||||
"tryItOut": "试试看"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,11 @@
|
||||
},
|
||||
"Comfy-Desktop_WindowStyle": {
|
||||
"name": "窗口样式",
|
||||
"tooltip": "选择自定义选项以隐藏系统标题栏",
|
||||
"options": {
|
||||
"default": "默认",
|
||||
"custom": "自定义"
|
||||
}
|
||||
"custom": "自定义",
|
||||
"default": "默认"
|
||||
},
|
||||
"tooltip": "选择自定义选项以隐藏系统标题栏"
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "画布背景图像",
|
||||
@@ -46,9 +46,9 @@
|
||||
"Comfy_Canvas_NavigationMode": {
|
||||
"name": "画布导航模式",
|
||||
"options": {
|
||||
"Standard (New)": "标准(新)",
|
||||
"Custom": "自定义",
|
||||
"Drag Navigation": "拖动画布",
|
||||
"Custom": "自定义"
|
||||
"Standard (New)": "标准(新)"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_SelectionToolbox": {
|
||||
@@ -81,14 +81,14 @@
|
||||
},
|
||||
"Comfy_Execution_PreviewMethod": {
|
||||
"name": "实时预览",
|
||||
"tooltip": "图像生成过程中实时预览。 \"默认\" 使用服务器 CLI 设置。",
|
||||
"options": {
|
||||
"default": "默认",
|
||||
"none": "无",
|
||||
"auto": "自动",
|
||||
"default": "默认",
|
||||
"latent2rgb": "latent2rgb",
|
||||
"none": "无",
|
||||
"taesd": "taesd"
|
||||
}
|
||||
},
|
||||
"tooltip": "图像生成过程中实时预览。 \"默认\" 使用服务器 CLI 设置。"
|
||||
},
|
||||
"Comfy_FloatRoundingPrecision": {
|
||||
"name": "浮点组件四舍五入的小数位数 [0 = 自动]。",
|
||||
@@ -106,9 +106,9 @@
|
||||
"Comfy_Graph_LinkMarkers": {
|
||||
"name": "连线中点标记",
|
||||
"options": {
|
||||
"None": "无",
|
||||
"Arrow": "箭头",
|
||||
"Circle": "圆",
|
||||
"Arrow": "箭头"
|
||||
"None": "无"
|
||||
}
|
||||
},
|
||||
"Comfy_Graph_LiveSelection": {
|
||||
@@ -128,25 +128,25 @@
|
||||
"name": "释放连线时的操作",
|
||||
"options": {
|
||||
"context menu": "上下文菜单",
|
||||
"search box": "搜索框",
|
||||
"no action": "无操作"
|
||||
"no action": "无操作",
|
||||
"search box": "搜索框"
|
||||
}
|
||||
},
|
||||
"Comfy_LinkRelease_ActionShift": {
|
||||
"name": "释放连线时的操作(Shift)",
|
||||
"options": {
|
||||
"context menu": "上下文菜单",
|
||||
"search box": "搜索框",
|
||||
"no action": "无操作"
|
||||
"no action": "无操作",
|
||||
"search box": "搜索框"
|
||||
}
|
||||
},
|
||||
"Comfy_LinkRenderMode": {
|
||||
"name": "连线渲染样式",
|
||||
"options": {
|
||||
"Straight": "直角线",
|
||||
"Hidden": "隐藏",
|
||||
"Linear": "直线",
|
||||
"Spline": "曲线",
|
||||
"Hidden": "隐藏"
|
||||
"Straight": "直角线"
|
||||
}
|
||||
},
|
||||
"Comfy_Load3D_3DViewerEnable": {
|
||||
@@ -159,11 +159,11 @@
|
||||
},
|
||||
"Comfy_Load3D_CameraType": {
|
||||
"name": "摄像机类型",
|
||||
"tooltip": "控制创建新的3D小部件时,默认的相机是透视还是正交。这个默认设置仍然可以在创建后为每个小部件单独切换。",
|
||||
"options": {
|
||||
"perspective": "透视",
|
||||
"orthographic": "正交"
|
||||
}
|
||||
"orthographic": "正交",
|
||||
"perspective": "透视"
|
||||
},
|
||||
"tooltip": "控制创建新的3D小部件时,默认的相机是透视还是正交。这个默认设置仍然可以在创建后为每个小部件单独切换。"
|
||||
},
|
||||
"Comfy_Load3D_LightAdjustmentIncrement": {
|
||||
"name": "光照调整步长",
|
||||
@@ -183,12 +183,12 @@
|
||||
},
|
||||
"Comfy_Load3D_PLYEngine": {
|
||||
"name": "PLY 引擎",
|
||||
"tooltip": "选择加载 PLY 文件的引擎。 \"threejs\" 使用原生 Three.js PLY 加载器(最适合网格 PLY)。 \"fastply\" 使用专用于 ASCII 点云的 PLY 文件加载器。 \"sparkjs\" 使用 Spark.js 加载 3D 高斯泼溅 PLY 文件。",
|
||||
"options": {
|
||||
"threejs": "threejs",
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs"
|
||||
}
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "选择加载 PLY 文件的引擎。 \"threejs\" 使用原生 Three.js PLY 加载器(最适合网格 PLY)。 \"fastply\" 使用专用于 ASCII 点云的 PLY 文件加载器。 \"sparkjs\" 使用 Spark.js 加载 3D 高斯泼溅 PLY 文件。"
|
||||
},
|
||||
"Comfy_Load3D_ShowGrid": {
|
||||
"name": "显示网格",
|
||||
@@ -211,11 +211,11 @@
|
||||
},
|
||||
"Comfy_ModelLibrary_NameFormat": {
|
||||
"name": "在模型库树视图中显示的名称",
|
||||
"tooltip": "选择“文件名”以在模型列表中显示原始文件名的简化视图(不带目录和“.safetensors”后缀名)。选择“标题”以显示可配置的模型元数据标题。",
|
||||
"options": {
|
||||
"filename": "文件名",
|
||||
"title": "标题"
|
||||
}
|
||||
},
|
||||
"tooltip": "选择“文件名”以在模型列表中显示原始文件名的简化视图(不带目录和“.safetensors”后缀名)。选择“标题”以显示可配置的模型元数据标题。"
|
||||
},
|
||||
"Comfy_Node_AllowImageSizeDraw": {
|
||||
"name": "在图像预览下方显示宽度×高度"
|
||||
@@ -266,9 +266,9 @@
|
||||
"Comfy_NodeBadge_NodeSourceBadgeMode": {
|
||||
"name": "节点源标签",
|
||||
"options": {
|
||||
"Hide built-in": "仅第三方",
|
||||
"None": "无",
|
||||
"Show all": "显示全部",
|
||||
"Hide built-in": "仅第三方"
|
||||
"Show all": "显示全部"
|
||||
}
|
||||
},
|
||||
"Comfy_NodeBadge_ShowApiPricing": {
|
||||
@@ -349,8 +349,8 @@
|
||||
"Comfy_Sidebar_Style": {
|
||||
"name": "侧边栏样式",
|
||||
"options": {
|
||||
"floating": "浮动式",
|
||||
"connected": "连接式"
|
||||
"connected": "连接式",
|
||||
"floating": "浮动式"
|
||||
}
|
||||
},
|
||||
"Comfy_Sidebar_UnifiedWidth": {
|
||||
@@ -371,11 +371,11 @@
|
||||
},
|
||||
"Comfy_UseNewMenu": {
|
||||
"name": "使用新菜单",
|
||||
"tooltip": "选单列位置。在行动装置上,选单始终显示于顶端。",
|
||||
"options": {
|
||||
"Disabled": "禁用",
|
||||
"Top": "顶部"
|
||||
}
|
||||
},
|
||||
"tooltip": "选单列位置。在行动装置上,选单始终显示于顶端。"
|
||||
},
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "校验工作流"
|
||||
@@ -390,11 +390,11 @@
|
||||
},
|
||||
"Comfy_WidgetControlMode": {
|
||||
"name": "组件控制模式",
|
||||
"tooltip": "控制组件值的更新时机(随机/增加/减少),可以在执行工作流之前或之后。",
|
||||
"options": {
|
||||
"before": "之前",
|
||||
"after": "之后"
|
||||
}
|
||||
"after": "之后",
|
||||
"before": "之前"
|
||||
},
|
||||
"tooltip": "控制组件值的更新时机(随机/增加/减少),可以在执行工作流之前或之后。"
|
||||
},
|
||||
"Comfy_Window_UnloadConfirmation": {
|
||||
"name": "关闭窗口时显示确认"
|
||||
@@ -402,8 +402,8 @@
|
||||
"Comfy_Workflow_AutoSave": {
|
||||
"name": "自动保存",
|
||||
"options": {
|
||||
"off": "关闭",
|
||||
"after delay": "延迟后"
|
||||
"after delay": "延迟后",
|
||||
"off": "关闭"
|
||||
}
|
||||
},
|
||||
"Comfy_Workflow_AutoSaveDelay": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:key="badge.label"
|
||||
:class="
|
||||
cn(
|
||||
'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background'
|
||||
'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background break-all'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<Button
|
||||
v-if="isUploadButtonEnabled"
|
||||
variant="primary"
|
||||
:size="breakpoints.md ? 'md' : 'icon'"
|
||||
:size="breakpoints.md ? 'lg' : 'icon'"
|
||||
data-attr="upload-model-button"
|
||||
@click="showUploadDialog"
|
||||
>
|
||||
@@ -57,6 +57,7 @@
|
||||
:assets="filteredAssets"
|
||||
:loading="isLoading"
|
||||
@asset-select="handleAssetSelectAndEmit"
|
||||
@asset-deleted="refreshAssets"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!deletedLocal"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
:aria-labelledby="titleId"
|
||||
@@ -139,8 +138,9 @@ const { asset, interactive } = defineProps<{
|
||||
interactive?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
select: [asset: AssetDisplayItem]
|
||||
deleted: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -158,7 +158,6 @@ const descId = useId()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const newNameRef = ref<string>()
|
||||
const deletedLocal = ref(false)
|
||||
|
||||
const displayName = computed(() => newNameRef.value ?? asset.name)
|
||||
|
||||
@@ -211,7 +210,7 @@ function confirmDeletion() {
|
||||
})
|
||||
// Give a second for the completion message
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||
deletedLocal.value = true
|
||||
emit('deleted', asset)
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
promptText.value = t('assetBrowser.deletion.failed', {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
@update:model-value="handleFilterChange"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down] size-3" />
|
||||
<i class="icon-[lucide--arrow-up-down]" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
<template>
|
||||
<div
|
||||
data-component-id="AssetGrid"
|
||||
:class="
|
||||
cn('grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4 p-2')
|
||||
"
|
||||
class="h-full"
|
||||
role="grid"
|
||||
:aria-label="$t('assetBrowser.assetCollection')"
|
||||
:aria-rowcount="-1"
|
||||
:aria-colcount="-1"
|
||||
:aria-setsize="assets.length"
|
||||
>
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="col-span-full flex items-center justify-center py-20"
|
||||
>
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-20">
|
||||
<i
|
||||
class="icon-[lucide--loader] size-12 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="assets.length === 0"
|
||||
class="col-span-full flex flex-col items-center justify-center py-16 text-muted-foreground"
|
||||
class="flex h-full flex-col items-center justify-center py-16 text-muted-foreground"
|
||||
>
|
||||
<i class="mb-4 icon-[lucide--search] size-10" />
|
||||
<h3 class="mb-2 text-lg font-medium">
|
||||
@@ -30,29 +23,51 @@
|
||||
</h3>
|
||||
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<AssetCard
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:interactive="true"
|
||||
@select="$emit('assetSelect', $event)"
|
||||
/>
|
||||
</template>
|
||||
<VirtualGrid
|
||||
v-else
|
||||
:items="assetsWithKey"
|
||||
:grid-style="gridStyle"
|
||||
:default-item-height="320"
|
||||
:default-item-width="240"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<AssetCard
|
||||
:asset="item"
|
||||
:interactive="true"
|
||||
@select="$emit('assetSelect', $event)"
|
||||
@deleted="$emit('assetDeleted', $event)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
const { assets } = defineProps<{
|
||||
assets: AssetDisplayItem[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
assetSelect: [asset: AssetDisplayItem]
|
||||
assetDeleted: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
const assetsWithKey = computed(() =>
|
||||
assets.map((asset) => ({ ...asset, key: asset.id }))
|
||||
)
|
||||
|
||||
const gridStyle: Partial<CSSProperties> = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
|
||||
gap: '1rem',
|
||||
padding: '0.5rem'
|
||||
}
|
||||
</script>
|
||||
|
||||
29
src/platform/assets/components/Media3DBottom.vue
Normal file
29
src/platform/assets/components/Media3DBottom.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div
|
||||
v-if="asset.size"
|
||||
class="flex items-center gap-2 text-xs text-zinc-400"
|
||||
>
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
<CardContainer
|
||||
ref="cardContainerRef"
|
||||
role="button"
|
||||
:aria-label="
|
||||
@@ -11,114 +11,105 @@
|
||||
: $t('assetBrowser.ariaLabel.loadingAsset')
|
||||
"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col overflow-hidden cursor-pointer p-2 transition-colors duration-200 rounded-lg',
|
||||
'gap-2 select-none group',
|
||||
selected
|
||||
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
|
||||
: 'hover:bg-modal-card-background-hovered'
|
||||
)
|
||||
"
|
||||
size="mini"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:class="containerClasses"
|
||||
:data-selected="selected"
|
||||
@click.stop="$emit('click')"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
<!-- Top Area: Media Preview -->
|
||||
<div class="relative aspect-square overflow-hidden p-0">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
|
||||
/>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<component
|
||||
:is="getTopComponent(fileKind)"
|
||||
v-else-if="asset && adaptedAsset"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
class="absolute inset-0"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset()"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
@image-loaded="handleImageLoaded"
|
||||
/>
|
||||
|
||||
<!-- Action buttons overlay (top-left) -->
|
||||
<div
|
||||
v-if="showActionsOverlay"
|
||||
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
<template #top>
|
||||
<CardTop
|
||||
ratio="square"
|
||||
:bottom-left-class="durationChipClasses"
|
||||
:bottom-right-class="durationChipClasses"
|
||||
>
|
||||
<IconGroup background-class="bg-white">
|
||||
<Button
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.zoom')"
|
||||
@click.stop="handleZoomClick"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="handleContextMenu"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</IconGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Area: Media Info -->
|
||||
<div class="flex-1">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-between items-start">
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div
|
||||
class="h-4 w-24 animate-pulse rounded bg-modal-card-background"
|
||||
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
|
||||
/>
|
||||
<div
|
||||
class="h-3 w-20 animate-pulse rounded bg-modal-card-background"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-6 w-12 animate-pulse rounded bg-modal-card-background" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
v-else-if="asset && adaptedAsset"
|
||||
class="flex justify-between items-end gap-1.5"
|
||||
>
|
||||
<!-- Left side: Media name and metadata -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Title -->
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- Metadata -->
|
||||
<div class="flex gap-1.5 text-xs text-muted-foreground">
|
||||
<span v-if="formattedDuration">{{ formattedDuration }}</span>
|
||||
<span v-if="metaInfo">{{ metaInfo }}</span>
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset && adaptedAsset">
|
||||
<component
|
||||
:is="getTopComponent(fileKind)"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset()"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
@image-loaded="handleImageLoaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Top-left slot: Duration/Format chips OR Media actions -->
|
||||
<template #top-left>
|
||||
<!-- Duration/Format chips - show when not hovered and not playing -->
|
||||
<div v-if="showStaticChips" class="flex flex-wrap items-center gap-1">
|
||||
<SquareChip
|
||||
v-if="formattedDuration"
|
||||
variant="gray"
|
||||
:label="formattedDuration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Output count -->
|
||||
<div v-if="showOutputCount" class="flex-shrink-0">
|
||||
<!-- Media actions - show on hover or when playing -->
|
||||
<IconGroup v-else-if="showActionsOverlay">
|
||||
<Button size="icon" @click.stop="handleZoomClick">
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</Button>
|
||||
<Button size="icon" @click.stop="handleContextMenu">
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<!-- Output count (top-right) -->
|
||||
<template v-if="showOutputCount" #top-right>
|
||||
<Button
|
||||
v-tooltip.top.pt:pointer-events-none="
|
||||
$t('mediaAsset.actions.seeMoreOutputs')
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click.stop="handleOutputCountClick"
|
||||
>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
<span>{{ outputCount }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div class="flex flex-col items-center justify-between gap-1">
|
||||
<div
|
||||
class="h-4 w-2/3 animate-pulse rounded bg-modal-card-background"
|
||||
/>
|
||||
<div
|
||||
class="h-3 w-1/2 animate-pulse rounded bg-modal-card-background"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset && adaptedAsset">
|
||||
<component
|
||||
:is="getBottomComponent(fileKind)"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
/>
|
||||
</template>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
|
||||
<MediaAssetContextMenu
|
||||
v-if="asset"
|
||||
@@ -137,13 +128,12 @@ import { useElementHover, whenever } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { getAssetType } from '../composables/media/assetMappers'
|
||||
@@ -152,7 +142,6 @@ import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
@@ -160,6 +149,12 @@ const mediaComponents = {
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
},
|
||||
bottom: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +162,10 @@ function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
function getBottomComponent(kind: MediaKind) {
|
||||
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
||||
}
|
||||
|
||||
const {
|
||||
asset,
|
||||
loading,
|
||||
@@ -216,11 +215,6 @@ const fileKind = computed((): MediaKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
|
||||
})
|
||||
|
||||
// Get filename without extension
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset?.name || '').filename
|
||||
})
|
||||
|
||||
// Adapt AssetItem to legacy AssetMeta format for existing components
|
||||
const adaptedAsset = computed(() => {
|
||||
if (!asset) return undefined
|
||||
@@ -246,6 +240,15 @@ provide(MediaAssetKey, {
|
||||
showVideoControls
|
||||
})
|
||||
|
||||
const containerClasses = computed(() =>
|
||||
cn(
|
||||
'gap-1 select-none group',
|
||||
selected
|
||||
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
|
||||
: 'hover:bg-modal-card-background-hovered'
|
||||
)
|
||||
)
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
// Check for execution time first (from history API)
|
||||
const executionTime = asset?.user_metadata?.executionTimeInSeconds
|
||||
@@ -259,22 +262,30 @@ const formattedDuration = computed(() => {
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
// Get metadata info based on file kind
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
if (fileKind.value === 'image' && imageDimensions.value) {
|
||||
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
|
||||
const durationChipClasses = computed(() => {
|
||||
if (fileKind.value === 'audio') {
|
||||
return '-translate-y-11'
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
if (fileKind.value === 'video' && showVideoControls.value) {
|
||||
return '-translate-y-16'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const showActionsOverlay = computed(() => {
|
||||
if (loading || !asset) return false
|
||||
return isHovered.value || selected || isVideoPlaying.value
|
||||
})
|
||||
// Show static chips when NOT hovered and NOT playing (normal state)
|
||||
const showStaticChips = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
!!asset &&
|
||||
!isHovered.value &&
|
||||
!isVideoPlaying.value &&
|
||||
formattedDuration.value
|
||||
)
|
||||
|
||||
// Show action overlay when hovered OR playing
|
||||
const showActionsOverlay = computed(
|
||||
() => !loading && !!asset && (isHovered.value || isVideoPlaying.value)
|
||||
)
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
|
||||
@@ -31,26 +31,18 @@
|
||||
/>
|
||||
</template>
|
||||
</AssetSortButton>
|
||||
<MediaAssetViewModeToggle
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
v-model:view-mode="viewMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
|
||||
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
|
||||
import AssetSortButton from './MediaAssetSortButton.vue'
|
||||
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
|
||||
import type { SortBy } from './MediaAssetSortMenu.vue'
|
||||
import MediaAssetViewModeToggle from './MediaAssetViewModeToggle.vue'
|
||||
|
||||
const { showGenerationTimeSort = false } = defineProps<{
|
||||
searchQuery: string
|
||||
@@ -64,12 +56,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
const handleSearchChange = (value: string | undefined) => {
|
||||
emit('update:searchQuery', value ?? '')
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-secondary-background p-1"
|
||||
role="group"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.viewList')"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg',
|
||||
viewMode === 'list'
|
||||
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
|
||||
: 'text-text-secondary hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<i class="icon-[lucide--table-of-contents] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.viewGrid')"
|
||||
:aria-pressed="viewMode === 'grid'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg',
|
||||
viewMode === 'grid'
|
||||
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
|
||||
: 'text-text-secondary hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
29
src/platform/assets/components/MediaAudioBottom.vue
Normal file
29
src/platform/assets/components/MediaAudioBottom.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div
|
||||
v-if="asset.size"
|
||||
class="flex items-center gap-2 text-xs text-zinc-400"
|
||||
>
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
27
src/platform/assets/components/MediaImageBottom.vue
Normal file
27
src/platform/assets/components/MediaImageBottom.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span v-if="asset.dimensions"
|
||||
>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
@@ -1,14 +1,21 @@
|
||||
<template>
|
||||
<p
|
||||
class="m-0 line-clamp-2 text-sm text-base-foreground leading-tight break-all"
|
||||
:title="fileName"
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-base-foreground"
|
||||
:title="fullName"
|
||||
>
|
||||
{{ fileName }}
|
||||
</p>
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { truncateFilename } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
fileName: string
|
||||
}>()
|
||||
|
||||
const fullName = computed(() => props.fileName)
|
||||
const displayName = computed(() => truncateFilename(props.fileName))
|
||||
</script>
|
||||
|
||||
26
src/platform/assets/components/MediaVideoBottom.vue
Normal file
26
src/platform/assets/components/MediaVideoBottom.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div v-if="asset.size" class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
257
src/platform/assets/components/ModelImportProgressDialog.vue
Normal file
257
src/platform/assets/components/ModelImportProgressDialog.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
|
||||
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
|
||||
const visible = computed(() => assetDownloadStore.hasDownloads)
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const activeFilter = ref<'all' | 'completed' | 'failed'>('all')
|
||||
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
whenever(
|
||||
() => !isExpanded.value,
|
||||
() => filterPopoverRef.value?.hide()
|
||||
)
|
||||
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: 'all' },
|
||||
{ value: 'completed', label: 'completed' },
|
||||
{ value: 'failed', label: 'failed' }
|
||||
] as const
|
||||
|
||||
function onFilterClick(event: Event) {
|
||||
filterPopoverRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
function setFilter(filter: typeof activeFilter.value) {
|
||||
activeFilter.value = filter
|
||||
filterPopoverRef.value?.hide()
|
||||
}
|
||||
|
||||
const downloadJobs = computed(() => assetDownloadStore.downloadList)
|
||||
const completedJobs = computed(() =>
|
||||
assetDownloadStore.finishedDownloads.filter((d) => d.status === 'completed')
|
||||
)
|
||||
const failedJobs = computed(() =>
|
||||
assetDownloadStore.finishedDownloads.filter((d) => d.status === 'failed')
|
||||
)
|
||||
|
||||
const isInProgress = computed(() => assetDownloadStore.hasActiveDownloads)
|
||||
const currentJobName = computed(() => {
|
||||
const activeJob = downloadJobs.value.find((job) => job.status === 'running')
|
||||
return activeJob?.assetName || t('progressToast.downloadingModel')
|
||||
})
|
||||
|
||||
const completedCount = computed(
|
||||
() => completedJobs.value.length + failedJobs.value.length
|
||||
)
|
||||
const totalCount = computed(() => downloadJobs.value.length)
|
||||
|
||||
const filteredJobs = computed(() => {
|
||||
switch (activeFilter.value) {
|
||||
case 'completed':
|
||||
return completedJobs.value
|
||||
case 'failed':
|
||||
return failedJobs.value
|
||||
default:
|
||||
return downloadJobs.value
|
||||
}
|
||||
})
|
||||
|
||||
const activeFilterLabel = computed(() => {
|
||||
const option = filterOptions.find((f) => f.value === activeFilter.value)
|
||||
return option
|
||||
? t(`progressToast.filter.${option.label}`)
|
||||
: t('progressToast.filter.all')
|
||||
})
|
||||
|
||||
function closeDialog() {
|
||||
assetDownloadStore.clearFinishedDownloads()
|
||||
isExpanded.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoneyToast v-model:expanded="isExpanded" :visible>
|
||||
<template #default>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h3 class="text-sm font-bold text-base-foreground">
|
||||
{{ t('progressToast.importingModels') }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-1.5 px-2"
|
||||
@click="onFilterClick"
|
||||
>
|
||||
<i class="icon-[lucide--list-filter] size-4" />
|
||||
<span>{{ activeFilterLabel }}</span>
|
||||
<i class="icon-[lucide--chevron-down] size-3" />
|
||||
</Button>
|
||||
<Popover
|
||||
ref="filterPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:base-z-index="9999"
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class:
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-30 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<Button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.value"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
:class="
|
||||
cn(
|
||||
'w-full justify-start bg-transparent',
|
||||
activeFilter === option.value &&
|
||||
'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="setFilter(option.value)"
|
||||
>
|
||||
{{ t(`progressToast.filter.${option.label}`) }}
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-h-75 overflow-y-auto px-4 py-4">
|
||||
<div
|
||||
v-if="filteredJobs.length > 3"
|
||||
class="absolute right-1 top-4 h-12 w-1 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<ProgressToastItem
|
||||
v-for="job in filteredJobs"
|
||||
:key="job.taskId"
|
||||
:job="job"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredJobs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-center"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
t('progressToast.noImportsInQueue', {
|
||||
filter: activeFilterLabel
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer="{ toggle }">
|
||||
<div
|
||||
class="flex h-12 items-center justify-between gap-2 border-t border-border-default px-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<template v-if="isInProgress">
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 flex-shrink-0 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate font-bold text-base-foreground"
|
||||
>
|
||||
{{ currentJobName }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="failedJobs.length > 0">
|
||||
<i
|
||||
class="icon-[lucide--circle-alert] size-4 flex-shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span class="min-w-0 truncate font-bold text-base-foreground">
|
||||
{{
|
||||
t('progressToast.downloadsFailed', {
|
||||
count: failedJobs.length
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i
|
||||
class="icon-[lucide--check-circle] size-4 flex-shrink-0 text-jade-600"
|
||||
/>
|
||||
<span class="min-w-0 truncate font-bold text-base-foreground">
|
||||
{{ t('progressToast.allDownloadsCompleted') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<span
|
||||
v-if="isInProgress"
|
||||
class="whitespace-nowrap text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('progressToast.progressCount', {
|
||||
completed: completedCount,
|
||||
total: totalCount
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
isExpanded ? t('contextMenu.Collapse') : t('contextMenu.Expand')
|
||||
"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
isExpanded
|
||||
? 'icon-[lucide--chevron-down]'
|
||||
: 'icon-[lucide--chevron-up]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="!isInProgress"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('g.close')"
|
||||
@click.stop="closeDialog"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoneyToast>
|
||||
</template>
|
||||
@@ -5,15 +5,15 @@
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-3 bg-secondary-background p-3 rounded-lg"
|
||||
class="flex items-center gap-3 rounded-lg bg-secondary-background px-4 py-2"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
:src="previewImage"
|
||||
:alt="metadata?.filename || metadata?.name || 'Model preview'"
|
||||
class="w-14 h-14 rounded object-cover flex-shrink-0"
|
||||
class="size-14 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
<p class="m-0 text-base-foreground">
|
||||
<p class="m-0 min-w-0 flex-1 truncate text-base-foreground">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -21,9 +21,15 @@
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="">
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label>
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('assetBrowser.notSureLeaveAsIs') }}
|
||||
</span>
|
||||
</div>
|
||||
<SingleSelect
|
||||
v-model="modelValue"
|
||||
:label="
|
||||
@@ -35,10 +41,6 @@
|
||||
:disabled="isLoading"
|
||||
data-attr="upload-model-step2-type-selector"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--circle-question-mark]" />
|
||||
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t border-border-default"
|
||||
class="upload-model-dialog flex flex-col gap-6 border-t border-border-default p-4 pt-6"
|
||||
>
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
class="flex-1"
|
||||
/>
|
||||
<UploadModelUrlInputCivitai
|
||||
v-else-if="currentStep === 1"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
/>
|
||||
<!-- Scrollable content area -->
|
||||
<div class="min-h-0 flex-auto basis-0 overflow-y-auto">
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
/>
|
||||
<UploadModelUrlInputCivitai
|
||||
v-else-if="currentStep === 1"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
/>
|
||||
|
||||
<!-- Step 2: Confirm Metadata -->
|
||||
<UploadModelConfirmation
|
||||
v-else-if="currentStep === 2"
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
:preview-image="wizardData.previewImage"
|
||||
/>
|
||||
<!-- Step 2: Confirm Metadata -->
|
||||
<UploadModelConfirmation
|
||||
v-else-if="currentStep === 2"
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
:preview-image="wizardData.previewImage"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
<UploadModelProgress
|
||||
v-else-if="currentStep === 3 && uploadStatus != null"
|
||||
:result="uploadStatus"
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
:preview-image="wizardData.previewImage"
|
||||
/>
|
||||
<!-- Step 3: Upload Progress -->
|
||||
<UploadModelProgress
|
||||
v-else-if="currentStep === 3 && uploadStatus != null"
|
||||
:result="uploadStatus"
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
:preview-image="wizardData.previewImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
<!-- Navigation Footer - always visible -->
|
||||
<UploadModelFooter
|
||||
class="flex-shrink-0"
|
||||
:current-step="currentStep"
|
||||
:is-fetching-metadata="isFetchingMetadata"
|
||||
:is-uploading="isUploading"
|
||||
@@ -45,6 +48,7 @@
|
||||
@fetch-metadata="handleFetchMetadata"
|
||||
@upload="handleUploadModel"
|
||||
@close="handleClose"
|
||||
@import-another="resetWizard"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -82,7 +86,8 @@ const {
|
||||
canUploadModel,
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
goToPreviousStep,
|
||||
resetWizard
|
||||
} = useUploadModelWizard(modelTypes)
|
||||
|
||||
async function handleFetchMetadata() {
|
||||
@@ -109,7 +114,8 @@ onMounted(() => {
|
||||
.upload-model-dialog {
|
||||
width: 90vw;
|
||||
max-width: 800px;
|
||||
min-height: 400px;
|
||||
min-height: min(400px, 80vh);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
|
||||
@@ -80,21 +80,33 @@
|
||||
<i v-if="isUploading" class="icon-[lucide--loader-circle] animate-spin" />
|
||||
<span>{{ $t('assetBrowser.upload') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
<template
|
||||
v-else-if="
|
||||
currentStep === 3 &&
|
||||
(uploadStatus === 'success' || uploadStatus === 'processing')
|
||||
"
|
||||
variant="secondary"
|
||||
data-attr="upload-model-step3-finish-button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{
|
||||
uploadStatus === 'processing'
|
||||
? $t('g.close')
|
||||
: $t('assetBrowser.finish')
|
||||
}}
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
data-attr="upload-model-step3-import-another-button"
|
||||
@click="emit('importAnother')"
|
||||
>
|
||||
{{ $t('assetBrowser.importAnother') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
data-attr="upload-model-step3-finish-button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{
|
||||
uploadStatus === 'processing'
|
||||
? $t('g.close')
|
||||
: $t('assetBrowser.finish')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
<VideoHelpDialog
|
||||
v-model="showCivitaiHelp"
|
||||
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
|
||||
@@ -134,5 +146,6 @@ const emit = defineEmits<{
|
||||
(e: 'fetchMetadata'): void
|
||||
(e: 'upload'): void
|
||||
(e: 'close'): void
|
||||
(e: 'importAnother'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -10,19 +10,21 @@
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
|
||||
class="flex flex-row items-center gap-3 rounded-lg bg-modal-card-background p-4"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
:src="previewImage"
|
||||
:alt="metadata?.filename || metadata?.name || 'Model preview'"
|
||||
class="w-14 h-14 rounded object-cover flex-shrink-0"
|
||||
class="size-14 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-base-foreground m-0">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col items-start justify-center gap-1"
|
||||
>
|
||||
<p class="m-0 w-full truncate text-base-foreground">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
<p class="m-0 text-sm text-muted">
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -39,20 +41,21 @@
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
|
||||
class="flex flex-row items-center gap-3 rounded-lg bg-modal-card-background p-4"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
:src="previewImage"
|
||||
:alt="metadata?.filename || metadata?.name || 'Model preview'"
|
||||
class="w-14 h-14 rounded object-cover flex-shrink-0"
|
||||
class="size-14 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-base-foreground m-0">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col items-start justify-center gap-1"
|
||||
>
|
||||
<p class="m-0 w-full truncate text-base-foreground">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
<!-- Going to want to add another translation here to get a nice display name. -->
|
||||
<p class="m-0 text-sm text-muted">
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
:href="civitaiUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted underline"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.providerCivitai') }}</a
|
||||
><span>,</span>
|
||||
@@ -35,7 +35,7 @@
|
||||
:href="huggingFaceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted underline"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.providerHuggingFace') }}
|
||||
</a>
|
||||
@@ -45,14 +45,20 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
|
||||
class="w-full bg-secondary-background border-0 p-4"
|
||||
data-attr="upload-model-step1-url-input"
|
||||
/>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
<div class="relative">
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
|
||||
class="w-full border-0 bg-secondary-background p-4 pr-10"
|
||||
data-attr="upload-model-step1-url-input"
|
||||
/>
|
||||
<i
|
||||
v-if="isValidUrl"
|
||||
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="!flags.asyncModelUploadEnabled" class="text-foreground">
|
||||
@@ -78,6 +84,9 @@ import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
@@ -95,6 +104,14 @@ const url = computed({
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const importSources = [civitaiImportSource, huggingfaceImportSource]
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
const trimmedUrl = url.value.trim()
|
||||
if (!trimmedUrl) return false
|
||||
return importSources.some((source) => validateSourceUrl(trimmedUrl, source))
|
||||
})
|
||||
|
||||
const civitaiIcon = '/assets/images/civitai.svg'
|
||||
const civitaiUrl = 'https://civitai.com/models'
|
||||
const huggingFaceIcon = '/assets/images/hf-logo.svg'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a
|
||||
href="https://civitai.com/models"
|
||||
target="_blank"
|
||||
class="text-muted-foreground"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.uploadModelDescription2Link') }}
|
||||
</a>
|
||||
@@ -38,21 +38,27 @@
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
class="w-full bg-secondary-background border-0 p-4"
|
||||
data-attr="upload-model-step1-url-input"
|
||||
/>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
<div class="relative">
|
||||
<InputText
|
||||
v-model="url"
|
||||
autofocus
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
class="w-full border-0 bg-secondary-background p-4 pr-10"
|
||||
data-attr="upload-model-step1-url-input"
|
||||
/>
|
||||
<i
|
||||
v-if="isValidUrl"
|
||||
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="text-sm text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<i18n-t
|
||||
v-else
|
||||
keypath="assetBrowser.civitaiLinkExample"
|
||||
tag="p"
|
||||
class="text-xs"
|
||||
class="text-sm"
|
||||
>
|
||||
<template #example>
|
||||
<strong>{{ $t('assetBrowser.civitaiLinkExampleStrong') }}</strong>
|
||||
@@ -61,7 +67,7 @@
|
||||
<a
|
||||
href="https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295"
|
||||
target="_blank"
|
||||
class="text-muted-foreground"
|
||||
class="text-muted-foreground underline"
|
||||
>
|
||||
{{ $t('assetBrowser.civitaiLinkExampleUrl') }}
|
||||
</a>
|
||||
@@ -73,8 +79,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
@@ -83,4 +92,10 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const url = defineModel<string>({ required: true })
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
const trimmedUrl = url.value.trim()
|
||||
if (!trimmedUrl) return false
|
||||
return validateSourceUrl(trimmedUrl, civitaiImportSource)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
content: { class: '!p-0' },
|
||||
mask: { class: '!bg-black/70' }
|
||||
}"
|
||||
:style="{ width: '90vw', maxWidth: '800px' }"
|
||||
:style="{ width: '90vw' }"
|
||||
>
|
||||
<div class="relative">
|
||||
<Button
|
||||
|
||||
@@ -15,7 +15,18 @@ export type OwnershipOption = 'all' | 'my-models' | 'public-models'
|
||||
|
||||
function filterByCategory(category: string) {
|
||||
return (asset: AssetItem) => {
|
||||
return category === 'all' || asset.tags.includes(category)
|
||||
if (category === 'all') return true
|
||||
|
||||
// Check if any tag matches the category (for exact matches)
|
||||
if (asset.tags.includes(category)) return true
|
||||
|
||||
// Check if any tag's top-level folder matches the category
|
||||
return asset.tags.some((tag) => {
|
||||
if (typeof tag === 'string' && tag.includes('/')) {
|
||||
return tag.split('/')[0] === category
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +104,12 @@ export function useAssetBrowser(
|
||||
|
||||
// Type badge from non-root tag
|
||||
if (typeTag) {
|
||||
badges.push({ label: typeTag, type: 'type' })
|
||||
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
|
||||
const badgeLabel = typeTag.includes('/')
|
||||
? typeTag.substring(typeTag.indexOf('/') + 1)
|
||||
: typeTag
|
||||
|
||||
badges.push({ label: badgeLabel, type: 'type' })
|
||||
}
|
||||
|
||||
// Base model badge from metadata
|
||||
@@ -125,6 +141,7 @@ export function useAssetBrowser(
|
||||
.filter((asset) => asset.tags[0] === 'models')
|
||||
.map((asset) => asset.tags[1])
|
||||
.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)
|
||||
.map((tag) => tag.split('/')[0]) // Extract top-level folder name
|
||||
|
||||
const uniqueCategories = Array.from(new Set(categories))
|
||||
.sort()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
@@ -25,7 +25,6 @@ import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import { assetService } from '../services/assetService'
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const mediaContext = inject(MediaAssetKey, null)
|
||||
@@ -80,7 +79,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', { count: 1 }),
|
||||
detail: t('g.downloadStarted'),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,14 +7,10 @@ import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
ResultItemImpl: vi
|
||||
.fn<typeof ResultItemImpl>()
|
||||
.mockImplementation(function (data) {
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
url: ''
|
||||
})
|
||||
})
|
||||
ResultItemImpl: vi.fn().mockImplementation((data) => ({
|
||||
...data,
|
||||
url: ''
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useMediaAssetGalleryStore', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user