mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 09:45:13 +00:00
Compare commits
37 Commits
v1.37.2
...
claude/sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d930514bea | ||
|
|
99cb7a2da1 | ||
|
|
b3d87673ec | ||
|
|
6a733918a7 | ||
|
|
a87d2cf1bd | ||
|
|
a1d689d3b3 | ||
|
|
dc64e16f7c | ||
|
|
c19a004f0d | ||
|
|
626d8dac70 | ||
|
|
b6a12ddae1 | ||
|
|
11f8cdb9bd | ||
|
|
dcf0886d89 | ||
|
|
ab6678534f | ||
|
|
ea3b3ceb00 | ||
|
|
2356b0bc9e | ||
|
|
dad1eafecc | ||
|
|
6e5dfc0109 | ||
|
|
43f0ac2e8f | ||
|
|
76a0b0b4b4 | ||
|
|
e6e93f2ebf | ||
|
|
372890811d | ||
|
|
14d0ec73f6 | ||
|
|
fbdaf5d7f3 | ||
|
|
a7d0825a14 | ||
|
|
10feb1fd5b | ||
|
|
832588c7a9 | ||
|
|
005633716e | ||
|
|
a326cb36a1 | ||
|
|
a13aa90875 | ||
|
|
05028894e5 | ||
|
|
87f560c713 | ||
|
|
7fcfa4c201 | ||
|
|
8d1f8edc5a | ||
|
|
825ec722c3 | ||
|
|
664aafb705 | ||
|
|
675a67cfda | ||
|
|
4c955f6725 |
1
THIRD_PARTY_NOTICES.md
Normal file
1
THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1 @@
|
||||
AMD and the AMD Arrow logo are trademarks of Advanced Micro Devices, Inc.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/desktop-ui",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"nx": {
|
||||
"tags": [
|
||||
|
||||
BIN
apps/desktop-ui/public/assets/images/amd-rocm-logo.png
Normal file
BIN
apps/desktop-ui/public/assets/images/amd-rocm-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
84
apps/desktop-ui/src/components/install/GpuPicker.stories.ts
Normal file
84
apps/desktop-ui/src/components/install/GpuPicker.stories.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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,29 +11,32 @@
|
||||
<!-- 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')"
|
||||
/>
|
||||
<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')"
|
||||
/>
|
||||
<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>
|
||||
<!-- CPU -->
|
||||
<HardwareOption
|
||||
placeholder-text="CPU"
|
||||
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
|
||||
:value="'cpu'"
|
||||
:selected="selected === 'cpu'"
|
||||
@click="pickGpu('cpu')"
|
||||
/>
|
||||
@@ -41,7 +44,6 @@
|
||||
<HardwareOption
|
||||
placeholder-text="Manual Install"
|
||||
:subtitle="$t('install.gpuPicker.manualSubtitle')"
|
||||
:value="'unsupported'"
|
||||
:selected="selected === 'unsupported'"
|
||||
@click="pickGpu('unsupported')"
|
||||
/>
|
||||
@@ -81,13 +83,15 @@ const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
const electron = electronAPI()
|
||||
const platform = electron.getPlatform()
|
||||
|
||||
const showRecommendedBadge = computed(
|
||||
() => selected.value === 'mps' || selected.value === 'nvidia'
|
||||
const recommendedDevices: TorchDeviceType[] = ['mps', 'nvidia', 'amd']
|
||||
const showRecommendedBadge = computed(() =>
|
||||
selected.value ? recommendedDevices.includes(selected.value) : false
|
||||
)
|
||||
|
||||
const descriptionKeys = {
|
||||
mps: 'appleMetal',
|
||||
nvidia: 'nvidia',
|
||||
amd: 'amd',
|
||||
cpu: 'cpu',
|
||||
unsupported: 'manual'
|
||||
} as const
|
||||
@@ -97,7 +101,7 @@ const descriptionText = computed(() => {
|
||||
return st(`install.gpuPicker.${key}Description`, '')
|
||||
})
|
||||
|
||||
const pickGpu = (value: TorchDeviceType) => {
|
||||
function pickGpu(value: TorchDeviceType) {
|
||||
selected.value = value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -29,7 +29,6 @@ export const AppleMetalSelected: Story = {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
@@ -39,7 +38,6 @@ export const AppleMetalUnselected: Story = {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -48,7 +46,6 @@ export const CPUOption: Story = {
|
||||
args: {
|
||||
placeholderText: 'CPU',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'cpu',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -57,7 +54,6 @@ export const ManualInstall: Story = {
|
||||
args: {
|
||||
placeholderText: 'Manual Install',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'unsupported',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
@@ -67,7 +63,6 @@ export const NvidiaSelected: Story = {
|
||||
imagePath: '/assets/images/nvidia-logo-square.jpg',
|
||||
placeholderText: 'NVIDIA',
|
||||
subtitle: 'NVIDIA',
|
||||
value: 'nvidia',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,13 @@
|
||||
</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 type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import type { TorchDeviceType } 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
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
@@ -170,6 +170,7 @@ const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'amd':
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -63,7 +63,6 @@ const taskStore = useMaintenanceTaskStore()
|
||||
defineProps<{
|
||||
displayAsList: string
|
||||
filter: MaintenanceFilter
|
||||
isRefreshing: boolean
|
||||
}>()
|
||||
|
||||
const executeTask = async (task: MaintenanceTask) => {
|
||||
|
||||
@@ -143,6 +143,8 @@ const goToPreviousStep = () => {
|
||||
const electron = electronAPI()
|
||||
const router = useRouter()
|
||||
const install = async () => {
|
||||
if (!device.value) return
|
||||
|
||||
const options: InstallOptions = {
|
||||
installPath: installPath.value,
|
||||
autoUpdate: autoUpdate.value,
|
||||
@@ -152,7 +154,6 @@ 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)
|
||||
@@ -166,7 +167,11 @@ onMounted(async () => {
|
||||
if (!electron) return
|
||||
|
||||
const detectedGpu = await electron.Config.getDetectedGpu()
|
||||
if (detectedGpu === 'mps' || detectedGpu === 'nvidia') {
|
||||
if (
|
||||
detectedGpu === 'mps' ||
|
||||
detectedGpu === 'nvidia' ||
|
||||
detectedGpu === 'amd'
|
||||
) {
|
||||
device.value = detectedGpu
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
||||
:filter
|
||||
:display-as-list
|
||||
:is-refreshing
|
||||
/>
|
||||
|
||||
<!-- Actions -->
|
||||
|
||||
@@ -19,6 +19,7 @@ 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(2) > div > span:has-text("Getting Started")'
|
||||
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
|
||||
)
|
||||
.click()
|
||||
await comfyPage.templates.loadTemplate('default')
|
||||
|
||||
66
docs/TEMPLATE_RANKING.md
Normal file
66
docs/TEMPLATE_RANKING.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
@@ -12,12 +12,17 @@ Documentation for unit tests is organized into three guides:
|
||||
|
||||
## Testing Structure
|
||||
|
||||
The ComfyUI Frontend project uses a mixed approach to unit test organization:
|
||||
The ComfyUI Frontend project uses **colocated tests** - test files are placed alongside their source files:
|
||||
|
||||
- **Component Tests**: Located directly alongside their components with a `.spec.ts` extension
|
||||
- **Unit Tests**: Located in the `tests-ui/tests/` directory
|
||||
- **Store Tests**: Located in the `tests-ui/tests/store/` directory
|
||||
- **Browser Tests**: These are located in the `browser_tests/` directory. There is a dedicated README in the `browser_tests/` directory, so it will not be covered here.
|
||||
- **Component Tests**: Located directly alongside their components (e.g., `MyComponent.test.ts` next to `MyComponent.vue`)
|
||||
- **Unit Tests**: Located alongside their source files (e.g., `myUtil.test.ts` next to `myUtil.ts`)
|
||||
- **Store Tests**: Located in `src/stores/` alongside their store files
|
||||
- **Browser Tests**: Located in the `browser_tests/` directory (see dedicated README there)
|
||||
|
||||
### Test File Naming
|
||||
|
||||
- Use `.test.ts` extension for test files
|
||||
- Name tests after their source file: `sourceFile.test.ts`
|
||||
|
||||
## Test Frameworks and Libraries
|
||||
|
||||
@@ -35,8 +40,11 @@ To run the tests locally:
|
||||
# Run unit tests
|
||||
pnpm test:unit
|
||||
|
||||
# Run a specific test file
|
||||
pnpm test:unit -- src/path/to/file.test.ts
|
||||
|
||||
# Run unit tests in watch mode
|
||||
pnpm test:unit -- --watch
|
||||
```
|
||||
|
||||
Refer to the specific guides for more detailed information on each testing type.
|
||||
Refer to the specific guides for more detailed information on each testing type.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.37.2",
|
||||
"version": "1.37.5",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -42,6 +42,7 @@
|
||||
"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",
|
||||
@@ -186,6 +187,7 @@
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
"vuefire": "catalog:",
|
||||
"wwobjloader2": "catalog:",
|
||||
"yjs": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
@config '../../tailwind.config.ts';
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@theme {
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getMediaTypeFromFilename, truncateFilename } from '@/utils/formatUtil'
|
||||
import { getMediaTypeFromFilename, truncateFilename } from './formatUtil'
|
||||
|
||||
describe('formatUtil', () => {
|
||||
describe('truncateFilename', () => {
|
||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -10,8 +10,8 @@ catalogs:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.5.5
|
||||
version: 0.5.5
|
||||
specifier: 0.6.2
|
||||
version: 0.6.2
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.1
|
||||
version: 9.39.1
|
||||
@@ -309,6 +309,9 @@ catalogs:
|
||||
vuefire:
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1
|
||||
wwobjloader2:
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
yjs:
|
||||
specifier: ^13.6.27
|
||||
version: 13.6.27
|
||||
@@ -337,7 +340,7 @@ importers:
|
||||
version: 1.3.1
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.5
|
||||
version: 0.6.2
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
@@ -497,6 +500,9 @@ importers:
|
||||
vuefire:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.1(consola@3.4.2)(firebase@11.6.0)(vue@3.5.13(typescript@5.9.3))
|
||||
wwobjloader2:
|
||||
specifier: 'catalog:'
|
||||
version: 6.2.1(three@0.170.0)
|
||||
yjs:
|
||||
specifier: 'catalog:'
|
||||
version: 13.6.27
|
||||
@@ -737,7 +743,7 @@ importers:
|
||||
dependencies:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.5
|
||||
version: 0.6.2
|
||||
'@comfyorg/shared-frontend-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-frontend-utils
|
||||
@@ -1477,8 +1483,8 @@ packages:
|
||||
'@cacheable/utils@2.3.2':
|
||||
resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.5.5':
|
||||
resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
|
||||
'@comfyorg/comfyui-electron-types@0.6.2':
|
||||
resolution: {integrity: sha512-r3By5Wbizq8jagUrhtcym79HYUTinsvoBnYkFFWbUmrURBWIaC0HduFVkRkI1PNdI76piW+JSOJJnw00YCVXeg==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
@@ -8225,6 +8231,19 @@ packages:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wtd-core@3.0.0:
|
||||
resolution: {integrity: sha512-LSPfAQ5ULSV5vPhipcjdQvV5xOx25QesYK23jwUOF99xOx6fuulk7CMQerERRwA4uoQooNmRd8AT6IPBwORlWQ==}
|
||||
|
||||
wtd-three-ext@3.0.0:
|
||||
resolution: {integrity: sha512-PLZJipCAiinot8D1uB4A7+XHxPAYeZXDhczbbazK7pKdqpE77zMizQH4rSZsaNbzktgnIfpgK/ODqhJTdrUjUw==}
|
||||
peerDependencies:
|
||||
three: '>= 0.137.5 < 1'
|
||||
|
||||
wwobjloader2@6.2.1:
|
||||
resolution: {integrity: sha512-/v/sfUX0PMQAI8souzCs6xsO9LR3RyL+ujnOiS/1pngUlakKyHYC5XMQvu77pTeWzY3rzNyt5Q/bg5O3RukA+g==}
|
||||
peerDependencies:
|
||||
three: '>= 0.137.5 < 1'
|
||||
|
||||
xdg-basedir@5.1.0:
|
||||
resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -9179,7 +9198,7 @@ snapshots:
|
||||
hashery: 1.3.0
|
||||
keyv: 5.5.5
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.5.5': {}
|
||||
'@comfyorg/comfyui-electron-types@0.6.2': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
@@ -17125,6 +17144,19 @@ snapshots:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
wtd-core@3.0.0: {}
|
||||
|
||||
wtd-three-ext@3.0.0(three@0.170.0):
|
||||
dependencies:
|
||||
three: 0.170.0
|
||||
wtd-core: 3.0.0
|
||||
|
||||
wwobjloader2@6.2.1(three@0.170.0):
|
||||
dependencies:
|
||||
three: 0.170.0
|
||||
wtd-core: 3.0.0
|
||||
wtd-three-ext: 3.0.0(three@0.170.0)
|
||||
|
||||
xdg-basedir@5.1.0: {}
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
@@ -4,7 +4,7 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@comfyorg/comfyui-electron-types': 0.5.5
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@eslint/js': ^9.39.1
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
@@ -104,6 +104,7 @@ catalog:
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.2.1
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yjs: ^13.6.27
|
||||
zod: ^3.23.8
|
||||
zod-to-json-schema: ^3.24.1
|
||||
|
||||
@@ -92,7 +92,8 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
@@ -106,8 +107,10 @@ const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const isTopMenuHovered = ref(false)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
@@ -133,7 +136,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'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'
|
||||
'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'
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -12,4 +13,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { backgroundClass } = defineProps<{
|
||||
backgroundClass?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
>
|
||||
<slot name="actions" :node="props.node" />
|
||||
</div>
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
<!-- Actual Template Cards -->
|
||||
<CardContainer
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="compact"
|
||||
@@ -405,6 +406,8 @@ 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'
|
||||
@@ -423,6 +426,30 @@ 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) {
|
||||
@@ -511,6 +538,9 @@ 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) {
|
||||
@@ -536,6 +566,36 @@ 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() {
|
||||
@@ -578,9 +638,6 @@ 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>('')
|
||||
|
||||
@@ -645,11 +702,19 @@ 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'
|
||||
@@ -750,7 +815,7 @@ const pageTitle = computed(() => {
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run both operations in parallel for better performance
|
||||
// Run all operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
@@ -763,6 +828,14 @@ 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" size="sm" @click="openManager">{{
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
|
||||
@@ -49,25 +49,66 @@
|
||||
@select="selectedCredits = option.credits"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground w-96">
|
||||
{{ $t('credits.topUp.templateNote') }}
|
||||
<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"
|
||||
>
|
||||
{{ t('subscription.videoTemplateBasedCredits') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buy Button -->
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
@click="handleBuy"
|
||||
<!-- Buy Button -->
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
</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'
|
||||
}
|
||||
}"
|
||||
>
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
</Button>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
>
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover } from 'primevue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -101,22 +142,28 @@ const toast = useToast()
|
||||
const selectedCredits = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const popover = ref()
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
|
||||
const creditOptions: CreditOption[] = [
|
||||
{
|
||||
credits: 1055, // $5.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 41 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 30 })
|
||||
},
|
||||
{
|
||||
credits: 2110, // $10.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 82 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 60 })
|
||||
},
|
||||
{
|
||||
credits: 4220, // $20.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 184 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 120 })
|
||||
},
|
||||
{
|
||||
credits: 10550, // $50.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 412 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 301 })
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
v-model:light-config="lightConfig"
|
||||
:is-splat-model="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:has-skeleton="hasSkeleton"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
@@ -33,6 +34,9 @@
|
||||
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
|
||||
@@ -113,12 +117,15 @@ const {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
animationDuration,
|
||||
loading,
|
||||
loadingMessage,
|
||||
|
||||
@@ -130,6 +137,7 @@ const {
|
||||
handleStopRecording,
|
||||
handleExportRecording,
|
||||
handleClearRecording,
|
||||
handleSeek,
|
||||
handleBackgroundImageUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
|
||||
@@ -58,8 +58,10 @@
|
||||
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
|
||||
@@ -99,9 +101,14 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isSplatModel = false, isPlyModel = false } = defineProps<{
|
||||
const {
|
||||
isSplatModel = false,
|
||||
isPlyModel = false,
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
isSplatModel?: boolean
|
||||
isPlyModel?: boolean
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="relative h-full w-full"
|
||||
class="relative h-full w-full min-h-[200px]"
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
@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"
|
||||
@@ -85,6 +95,7 @@
|
||||
<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,42 +1,64 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="animations && animations.length > 0"
|
||||
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full items-center justify-center gap-2 pt-2"
|
||||
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full flex-col items-center gap-2 pt-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']"
|
||||
<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>
|
||||
|
||||
<Select
|
||||
v-model="selectedSpeed"
|
||||
:options="speedOptions"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
class="w-24"
|
||||
/>
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
v-model="selectedAnimation"
|
||||
:options="animations"
|
||||
option-label="name"
|
||||
option-value="index"
|
||||
class="w-32"
|
||||
/>
|
||||
<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>
|
||||
</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 }
|
||||
|
||||
@@ -44,6 +66,16 @@ 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 },
|
||||
@@ -53,7 +85,25 @@ const speedOptions = [
|
||||
{ name: '2x', value: 2 }
|
||||
]
|
||||
|
||||
const togglePlay = () => {
|
||||
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() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function handleSliderChange(value: number[] | undefined) {
|
||||
if (!value) return
|
||||
const progress = value[0]
|
||||
animationProgress.value = progress
|
||||
emit('seek', progress)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -70,6 +70,22 @@
|
||||
</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>
|
||||
|
||||
@@ -84,13 +100,19 @@ import type {
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
const {
|
||||
hideMaterialMode = false,
|
||||
isPlyModel = false,
|
||||
hasSkeleton = 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,8 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
<h3 class="text-center text-[15px] font-sans text-descrip-text mt-2.5">
|
||||
{{ t('maskEditor.brushSettings') }}
|
||||
</h3>
|
||||
|
||||
@@ -10,120 +8,211 @@
|
||||
{{ t('maskEditor.resetToDefault') }}
|
||||
</button>
|
||||
|
||||
<!-- Brush Shape -->
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.brushShape')
|
||||
}}</span>
|
||||
<span class="text-left text-xs font-sans text-descrip-text">
|
||||
{{ t('maskEditor.brushShape') }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
|
||||
class="flex flex-row gap-2.5 items-center h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-comfy-menu-bg"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
|
||||
:style="{
|
||||
background:
|
||||
class="maskEditor_sidePanelBrushShapeCircle hover:bg-comfy-menu-bg"
|
||||
:class="
|
||||
cn(
|
||||
store.brushSettings.type === BrushShape.Arc
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
? 'bg-[var(--p-button-text-primary-color)] active'
|
||||
: 'bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="setBrushShape(BrushShape.Arc)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-comfy-menu-bg"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
|
||||
:style="{
|
||||
background:
|
||||
class="maskEditor_sidePanelBrushShapeSquare hover:bg-comfy-menu-bg"
|
||||
:class="
|
||||
cn(
|
||||
store.brushSettings.type === BrushShape.Rect
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
? 'bg-[var(--p-button-text-primary-color)] active'
|
||||
: 'bg-transparent'
|
||||
)
|
||||
"
|
||||
@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-[var(--descrip-text)]">{{
|
||||
t('maskEditor.colorSelector')
|
||||
}}</span>
|
||||
<input type="color" :value="store.rgbColor" @input="onColorChange" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.thickness')"
|
||||
:min="1"
|
||||
:max="500"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.size"
|
||||
@update:model-value="onThicknessChange"
|
||||
/>
|
||||
<!-- 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.opacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.opacity"
|
||||
@update:model-value="onOpacityChange"
|
||||
/>
|
||||
<!-- 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.hardness')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.hardness"
|
||||
@update:model-value="onHardnessChange"
|
||||
/>
|
||||
<!-- 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.stepSize')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.stepSize"
|
||||
@update:model-value="onStepSizeChange"
|
||||
/>
|
||||
<!-- 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>
|
||||
</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 textButtonClass =
|
||||
'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'
|
||||
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'
|
||||
|
||||
/* 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
const resetToDefault = () => {
|
||||
store.resetBrushToDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (colorInputRef.value) {
|
||||
store.colorInput = colorInputRef.value
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
store.colorInput = null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -202,6 +202,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
store.canvasHistory.clearStates()
|
||||
|
||||
store.resetState()
|
||||
dataStore.reset()
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ const QueueJobItemStub = defineComponent({
|
||||
runningNodeName: { type: String, default: undefined },
|
||||
activeDetailsId: { type: String, default: null }
|
||||
},
|
||||
emits: ['cancel', 'delete', 'menu', 'view', 'details-enter', 'details-leave'],
|
||||
template: '<div class="queue-job-item-stub"></div>'
|
||||
})
|
||||
|
||||
@@ -47,11 +47,36 @@
|
||||
<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'"
|
||||
/>
|
||||
<Divider type="dashed" class="my-2" />
|
||||
<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" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
@@ -164,7 +189,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import { Divider } from 'primevue'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -187,17 +212,26 @@ 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 } from '@/stores/queueStore'
|
||||
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
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)
|
||||
@@ -226,6 +260,19 @@ 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')
|
||||
@@ -490,6 +537,10 @@ const handleDeleteSelected = async () => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (
|
||||
activeTab.value === 'output' &&
|
||||
|
||||
@@ -18,7 +18,8 @@ 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'
|
||||
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -44,7 +45,8 @@ const variants = [
|
||||
'destructive',
|
||||
'textonly',
|
||||
'muted-textonly',
|
||||
'destructive-textonly'
|
||||
'destructive-textonly',
|
||||
'overlay-white'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
|
||||
ButtonVariants['size']
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<i :class="icon" class="text-neutral text-sm" />
|
||||
<i :class="icon" class="text-neutral text-sm shrink-0" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
:class="
|
||||
active
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
@@ -9,9 +9,11 @@
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<NavIcon v-if="icon" :icon="icon" />
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs" />
|
||||
<span class="flex items-center">
|
||||
<div v-if="icon" class="py-0.5">
|
||||
<NavIcon :icon="icon" />
|
||||
</div>
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
|
||||
<span class="flex items-center break-all">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,8 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
type Positionable,
|
||||
Reroute
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
@@ -47,6 +48,7 @@ export interface SafeWidgetData {
|
||||
borderStyle?: string
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
controlWidget?: SafeControlWidget
|
||||
hasLayoutSize?: boolean
|
||||
isDOMWidget?: boolean
|
||||
label?: string
|
||||
nodeType?: string
|
||||
@@ -171,7 +173,12 @@ export function safeWidgetMapper(
|
||||
const callback = (v: unknown) => {
|
||||
const value = normalizeWidgetValue(v)
|
||||
widget.value = value ?? undefined
|
||||
widget.callback?.(value)
|
||||
// Match litegraph callback signature: (value, canvas, node, pos, event)
|
||||
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
|
||||
widget.callback?.(value, app.canvas, node)
|
||||
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
|
||||
// This ensures widgets that depend on other widget values get updated
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -181,6 +188,7 @@ export function safeWidgetMapper(
|
||||
borderStyle,
|
||||
callback,
|
||||
controlWidget: getControlWidget(widget),
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
label: widget.label,
|
||||
nodeType: getNodeType(node, widget),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from '../../litegraph/subgraph/fixtures/subgraphFixtures'
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('useJobMenu', () => {
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
entry?.onClick?.()
|
||||
void entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
|
||||
error
|
||||
@@ -460,7 +460,7 @@ describe('useJobMenu', () => {
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
entry?.onClick?.()
|
||||
void entry?.onClick?.()
|
||||
|
||||
expect(downloadFileMock).toHaveBeenCalledWith('https://asset')
|
||||
})
|
||||
@@ -471,7 +471,7 @@ describe('useJobMenu', () => {
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
entry?.onClick?.()
|
||||
void entry?.onClick?.()
|
||||
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -9,8 +9,8 @@ import { app } from '@/scripts/app'
|
||||
|
||||
// Mock vue-i18n for useExternalLink
|
||||
const mockLocale = ref('en')
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: vi.fn(() => ({
|
||||
@@ -39,7 +39,11 @@ import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import {
|
||||
useQueueSettingsStore,
|
||||
useQueueStore,
|
||||
useQueueUIStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
@@ -63,7 +67,6 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -75,6 +78,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const executionStore = useExecutionStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
@@ -82,6 +86,14 @@ 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
|
||||
) => {
|
||||
@@ -423,6 +435,18 @@ 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',
|
||||
@@ -1175,6 +1199,12 @@ 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',
|
||||
|
||||
@@ -11,10 +11,12 @@ export enum ServerFeatureFlag {
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
|
||||
ASSET_DELETION_ENABLED = 'asset_deletion_enabled',
|
||||
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled'
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,14 +43,16 @@ export function useFeatureFlags() {
|
||||
)
|
||||
)
|
||||
},
|
||||
get assetUpdateOptionsEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
get assetDeletionEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_update_options_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
|
||||
false
|
||||
)
|
||||
remoteConfig.value.asset_deletion_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_DELETION_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get assetRenameEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_rename_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get privateModelsEnabled() {
|
||||
@@ -65,7 +69,6 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get huggingfaceModelImportEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.huggingface_model_import_enabled ??
|
||||
api.getServerFeature(
|
||||
@@ -73,6 +76,15 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get asyncModelUploadEnabled() {
|
||||
return (
|
||||
remoteConfig.value.async_model_upload_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.ASYNC_MODEL_UPLOAD_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ describe('useLoad3d', () => {
|
||||
},
|
||||
'Model Config': {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
},
|
||||
'Camera Config': {
|
||||
cameraType: 'perspective',
|
||||
@@ -107,6 +108,8 @@ describe('useLoad3d', () => {
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
hasSkeleton: vi.fn().mockReturnValue(false),
|
||||
setShowSkeleton: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
@@ -143,7 +146,8 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
expect(composable.modelConfig.value).toEqual({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
})
|
||||
expect(composable.cameraConfig.value).toEqual({
|
||||
cameraType: 'perspective',
|
||||
@@ -410,7 +414,8 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(mockNode.properties['Model Config']).toEqual({
|
||||
upDirection: '+y',
|
||||
materialMode: 'wireframe'
|
||||
materialMode: 'wireframe',
|
||||
showSkeleton: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -696,10 +701,13 @@ describe('useLoad3d', () => {
|
||||
'backgroundImageLoadingEnd',
|
||||
'modelLoadingStart',
|
||||
'modelLoadingEnd',
|
||||
'skeletonVisibilityChange',
|
||||
'exportLoadingStart',
|
||||
'exportLoadingEnd',
|
||||
'recordingStatusChange',
|
||||
'animationListChange'
|
||||
'animationListChange',
|
||||
'animationProgressChange',
|
||||
'cameraChanged'
|
||||
]
|
||||
|
||||
expectedEvents.forEach((event) => {
|
||||
@@ -40,9 +40,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const modelConfig = ref<ModelConfig>({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
})
|
||||
|
||||
const hasSkeleton = ref(false)
|
||||
|
||||
const cameraConfig = ref<CameraConfig>({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
@@ -60,6 +63,8 @@ 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)
|
||||
@@ -271,6 +276,7 @@ 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 }
|
||||
@@ -357,6 +363,13 @@ 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 = ''
|
||||
@@ -494,6 +507,12 @@ 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')
|
||||
@@ -514,6 +533,14 @@ 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) {
|
||||
@@ -567,12 +594,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
animationDuration,
|
||||
loading,
|
||||
loadingMessage,
|
||||
|
||||
@@ -585,6 +615,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleStopRecording,
|
||||
handleExportRecording,
|
||||
handleClearRecording,
|
||||
handleSeek,
|
||||
handleBackgroundImageUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -49,6 +50,14 @@ 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
|
||||
|
||||
@@ -174,6 +183,61 @@ 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)
|
||||
*/
|
||||
@@ -270,6 +334,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
}
|
||||
|
||||
setupAnimationEvents()
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d viewer:', error)
|
||||
useToastStore().addAlert(
|
||||
@@ -310,6 +376,8 @@ 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')
|
||||
@@ -527,6 +595,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
|
||||
// Animation state
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
animationDuration,
|
||||
|
||||
// Methods
|
||||
initializeViewer,
|
||||
initializeStandaloneViewer,
|
||||
@@ -539,6 +615,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
refreshViewport,
|
||||
handleBackgroundImageUpdate,
|
||||
handleModelDrop,
|
||||
handleSeek,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
@@ -19,10 +20,22 @@ const defaultSettingStore = {
|
||||
set: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
const defaultRankingStore = {
|
||||
computeDefaultScore: vi.fn(() => 0),
|
||||
computePopularScore: vi.fn(() => 0),
|
||||
getUsageScore: vi.fn(() => 0),
|
||||
computeFreshness: vi.fn(() => 0.5),
|
||||
isLoaded: { value: false }
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => defaultSettingStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/templateRankingStore', () => ({
|
||||
useTemplateRankingStore: vi.fn(() => defaultRankingStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackTemplateFilterChanged: vi.fn()
|
||||
@@ -34,6 +47,7 @@ const { useTemplateFiltering } =
|
||||
|
||||
describe('useTemplateFiltering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -258,4 +272,108 @@ describe('useTemplateFiltering', () => {
|
||||
'beta-pro'
|
||||
])
|
||||
})
|
||||
|
||||
it('incorporates search relevance into recommended sorting', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'wan-video-exact',
|
||||
title: 'Wan Video Template',
|
||||
description: 'A template with Wan in title',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-01-01',
|
||||
usage: 10
|
||||
},
|
||||
{
|
||||
name: 'qwen-image-partial',
|
||||
title: 'Qwen Image Editor',
|
||||
description: 'A template that contains w, a, n scattered',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-01-01',
|
||||
usage: 1000 // Higher usage but worse search match
|
||||
},
|
||||
{
|
||||
name: 'wan-text-exact',
|
||||
title: 'Wan2.5: Text to Image',
|
||||
description: 'Another exact match for Wan',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-01-01',
|
||||
usage: 50
|
||||
}
|
||||
])
|
||||
|
||||
const { searchQuery, sortBy, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
// Search for "Wan"
|
||||
searchQuery.value = 'Wan'
|
||||
sortBy.value = 'recommended'
|
||||
await nextTick()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
|
||||
// Templates with "Wan" in title should rank higher than Qwen despite lower usage
|
||||
// because search relevance is now factored into the recommended sort
|
||||
const results = filteredTemplates.value.map((t) => t.name)
|
||||
|
||||
// Verify exact matches appear (Qwen might be filtered out by threshold)
|
||||
expect(results).toContain('wan-video-exact')
|
||||
expect(results).toContain('wan-text-exact')
|
||||
|
||||
// If Qwen appears, it should be ranked lower than exact matches
|
||||
if (results.includes('qwen-image-partial')) {
|
||||
const wanIndex = results.indexOf('wan-video-exact')
|
||||
const qwenIndex = results.indexOf('qwen-image-partial')
|
||||
expect(wanIndex).toBeLessThan(qwenIndex)
|
||||
}
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves Fuse search order when using default sort', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'portrait-basic',
|
||||
title: 'Basic Portrait',
|
||||
description: 'A basic template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'portrait-pro',
|
||||
title: 'Portrait Pro Edition',
|
||||
description: 'Advanced portrait features',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'landscape-view',
|
||||
title: 'Landscape Generator',
|
||||
description: 'Generate landscapes',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { searchQuery, sortBy, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
searchQuery.value = 'Portrait Pro'
|
||||
sortBy.value = 'default'
|
||||
await nextTick()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
|
||||
const results = filteredTemplates.value.map((t) => t.name)
|
||||
|
||||
// With default sort, Fuse's relevance ordering is preserved
|
||||
// "Portrait Pro Edition" should be first as it's the best match
|
||||
expect(results[0]).toBe('portrait-pro')
|
||||
})
|
||||
})
|
||||
@@ -6,12 +6,14 @@ 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[]>(
|
||||
@@ -25,6 +27,8 @@ export function useTemplateFiltering(
|
||||
)
|
||||
const sortBy = ref<
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
@@ -78,13 +82,31 @@ export function useTemplateFiltering(
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
// 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) {
|
||||
return templatesArray.value
|
||||
}
|
||||
|
||||
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||
return results.map((result) => result.item)
|
||||
return fuseSearchResults.value.map((result) => result.item)
|
||||
})
|
||||
|
||||
const filteredByModels = computed(() => {
|
||||
@@ -151,10 +173,77 @@ 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 || ''
|
||||
@@ -173,6 +262,12 @@ 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)
|
||||
@@ -184,17 +279,25 @@ export function useTemplateFiltering(
|
||||
return vramA - vramB
|
||||
})
|
||||
case 'model-size-low-to-high':
|
||||
return templates.sort((a: any, b: any) => {
|
||||
return templates.sort((a, b) => {
|
||||
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) return 0
|
||||
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
|
||||
}
|
||||
return sizeA - sizeB
|
||||
})
|
||||
case 'default':
|
||||
default:
|
||||
// Keep original order (default order)
|
||||
// 'default' preserves Fuse's search order (already sorted by relevance)
|
||||
return templates
|
||||
}
|
||||
})
|
||||
@@ -206,7 +309,7 @@ export function useTemplateFiltering(
|
||||
selectedModels.value = []
|
||||
selectedUseCases.value = []
|
||||
selectedRunsOn.value = []
|
||||
sortBy.value = 'newest'
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
|
||||
const removeModelFilter = (model: string) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
const canvasEl: Partial<HTMLCanvasElement> = { addEventListener() {} }
|
||||
const canvas: Partial<LGraphCanvas> = { canvas: canvasEl as HTMLCanvasElement }
|
||||
@@ -94,7 +94,7 @@ function dynamicComboWidget(
|
||||
const newSpec = value ? options[value] : undefined
|
||||
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
remove(node.widgets, isInGroup)
|
||||
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
|
||||
|
||||
if (!newSpec) return
|
||||
|
||||
@@ -341,10 +341,16 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
//TODO: instead apply on output add?
|
||||
//ensure outputs get updated
|
||||
const index = node.inputs.length - 1
|
||||
const input = node.inputs.at(-1)!
|
||||
requestAnimationFrame(() =>
|
||||
node.onConnectionsChange(LiteGraph.INPUT, index, false, undefined, input)
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
const input = node.inputs.at(index)!
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
!!input.link,
|
||||
input.link ? node.graph?.links?.[input.link] : undefined,
|
||||
input
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function autogrowOrdinalToName(
|
||||
@@ -482,7 +488,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
for (const input of toRemove) {
|
||||
const widgetName = input?.widget?.name
|
||||
if (!widgetName) continue
|
||||
remove(node.widgets, (w) => w.name === widgetName)
|
||||
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
|
||||
widget.onRemove?.()
|
||||
}
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
@@ -196,8 +196,7 @@ export class GroupNodeConfig {
|
||||
primitiveToWidget: {}
|
||||
nodeInputs: {}
|
||||
outputVisibility: any[]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
nodeDef: ComfyNodeDef
|
||||
nodeDef: (ComfyNodeDef & { [GROUP]: GroupNodeConfig }) | undefined
|
||||
// @ts-expect-error fixme ts strict error
|
||||
inputs: any[]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -231,8 +230,7 @@ export class GroupNodeConfig {
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
// @ts-expect-error Unused, doesn't exist
|
||||
output_is_hidden: [],
|
||||
output_node: false, // This is a lie (to satisfy the interface)
|
||||
name: source + SEPARATOR + this.name,
|
||||
display_name: this.name,
|
||||
category: 'group nodes' + (SEPARATOR + source),
|
||||
@@ -261,6 +259,7 @@ 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,6 +125,13 @@ 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 {
|
||||
@@ -150,8 +157,58 @@ 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,8 +13,6 @@ 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
|
||||
@@ -42,10 +40,9 @@ 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,8 +156,9 @@ class Load3DConfiguration {
|
||||
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
} as ModelConfig
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
}
|
||||
}
|
||||
|
||||
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
||||
|
||||
@@ -392,7 +392,8 @@ class Load3d {
|
||||
this.STATUS_MOUSE_ON_SCENE ||
|
||||
this.STATUS_MOUSE_ON_VIEWER ||
|
||||
this.isRecording() ||
|
||||
!this.INITIAL_RENDER_DONE
|
||||
!this.INITIAL_RENDER_DONE ||
|
||||
this.animationManager.isAnimationPlaying
|
||||
)
|
||||
}
|
||||
|
||||
@@ -726,6 +727,32 @@ 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,9 +34,26 @@ 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)
|
||||
@@ -61,7 +78,7 @@ class Load3dUtils {
|
||||
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
console.error('[Load3D] uploadFile: exception', error)
|
||||
useToastStore().addAlert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
|
||||
@@ -3,9 +3,12 @@ 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'
|
||||
@@ -22,7 +25,7 @@ import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export class LoaderManager implements LoaderManagerInterface {
|
||||
gltfLoader: GLTFLoader
|
||||
objLoader: OBJLoader
|
||||
objLoader: OBJLoader2Parallel
|
||||
mtlLoader: MTLLoader
|
||||
fbxLoader: FBXLoader
|
||||
stlLoader: STLLoader
|
||||
@@ -41,7 +44,12 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
this.eventManager = eventManager
|
||||
|
||||
this.gltfLoader = new GLTFLoader()
|
||||
this.objLoader = new OBJLoader()
|
||||
this.objLoader = new OBJLoader2Parallel()
|
||||
// Set worker URL for Vite compatibility
|
||||
this.objLoader.setWorkerUrl(
|
||||
true,
|
||||
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
||||
)
|
||||
this.mtlLoader = new MTLLoader()
|
||||
this.fbxLoader = new FBXLoader()
|
||||
this.stlLoader = new STLLoader()
|
||||
@@ -160,6 +168,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
fbxModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
@@ -173,7 +185,9 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
|
||||
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
||||
materials.preload()
|
||||
this.objLoader.setMaterials(materials)
|
||||
const materialsFromMtl =
|
||||
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
||||
this.objLoader.setMaterials(materialsFromMtl)
|
||||
} catch (e) {
|
||||
console.log(
|
||||
'No MTL file found or error loading it, continuing without materials'
|
||||
@@ -181,8 +195,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
this.objLoader.setPath(path)
|
||||
model = await this.objLoader.loadAsync(filename)
|
||||
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
|
||||
const objUrl = path + encodeURIComponent(filename)
|
||||
model = await this.objLoader.loadAsync(objUrl)
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
@@ -193,7 +209,6 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
this.gltfLoader.setPath(path)
|
||||
|
||||
const gltf = await this.gltfLoader.loadAsync(filename)
|
||||
|
||||
this.modelManager.setOriginalModel(gltf)
|
||||
@@ -203,6 +218,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.computeVertexNormals()
|
||||
this.modelManager.originalMaterials.set(child, child.material)
|
||||
|
||||
if (child instanceof THREE.SkinnedMesh) {
|
||||
child.frustumCulled = false
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
@@ -27,13 +27,11 @@ 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
|
||||
@@ -41,7 +39,6 @@ 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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user