Compare commits

..

2 Commits

Author SHA1 Message Date
Comfy Org PR Bot
59e5c4745c 1.31.2 (#6764)
Patch version increment to 1.31.2

**Base branch:** `core/1.31`

Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-11-19 19:19:38 -08:00
Benjamin Lu
663bc9c2c1 Backport desktop update issue fixes to 1.31 (#6760)
Backport of these two PRs:

#6733
#6750

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6760-Backport-desktop-update-issues-2b16d73d365081dfb733ca68e3c5a8bc)
by [Unito](https://www.unito.io)
2025-11-19 19:53:31 -07:00
99 changed files with 4789 additions and 4012 deletions

3
.gitignore vendored
View File

@@ -92,6 +92,3 @@ storybook-static
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*
# Weekly docs check output
/output.txt

View File

@@ -91,7 +91,7 @@
"build-storybook": "storybook build -o dist/storybook"
},
"dependencies": {
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@primevue/core": "catalog:",
"@primevue/themes": "catalog:",

View File

@@ -115,19 +115,18 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { type ModelRef, computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import type { ModelRef } from 'vue'
import { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import type { UVMirror } from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil'
import { ValidationState } from '@/utils/validationUtil'
import MigrationPicker from './MigrationPicker.vue'
import MirrorItem from './mirror/MirrorItem.vue'
const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
@@ -229,6 +228,10 @@ const validatePath = async (path: string | undefined) => {
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.isInsideAppInstallDir)
errors.push(t('install.insideAppInstallDir'))
if (validation.isInsideUpdaterCache)
errors.push(t('install.insideUpdaterCache'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)

View File

@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
execute: async () => await electron.setBasePath(),
name: 'Base path',
shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.',
errorDescription:
'The current base path is invalid or unsafe. Please select a new location.',
description:
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,

View File

@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const electron = electronAPI()
// Reactive state
const lastUpdate = ref<InstallValidation | null>(null)
const isRefreshing = ref(false)
const isRunningTerminalCommand = computed(() =>
tasks.value
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
.some((task) => getRunner(task)?.executing)
)
const unsafeBasePath = computed(
() => lastUpdate.value?.unsafeBasePath === true
)
const unsafeBasePathReason = computed(
() => lastUpdate.value?.unsafeBasePathReason
)
// Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
}
const execute = async (task: MaintenanceTask) => {
return getRunner(task).execute(task)
const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()
}
return success
}
return {
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
isRefreshing,
isRunningTerminalCommand,
isRunningInstallationFix,
unsafeBasePath,
unsafeBasePathReason,
execute,
getRunner,
processUpdate,

View File

@@ -0,0 +1,159 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { defineAsyncComponent } from 'vue'
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
type ValidationState = {
inProgress: boolean
installState: string
basePath?: ValidationIssueState
unsafeBasePath: boolean
unsafeBasePathReason: UnsafeReason
venvDirectory?: ValidationIssueState
pythonInterpreter?: ValidationIssueState
pythonPackages?: ValidationIssueState
uv?: ValidationIssueState
git?: ValidationIssueState
vcRedist?: ValidationIssueState
upgradePackages?: ValidationIssueState
}
const validationState: ValidationState = {
inProgress: false,
installState: 'installed',
basePath: 'OK',
unsafeBasePath: false,
unsafeBasePathReason: null,
venvDirectory: 'OK',
pythonInterpreter: 'OK',
pythonPackages: 'OK',
uv: 'OK',
git: 'OK',
vcRedist: 'OK',
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
return {
getPlatform: () => 'darwin',
changeTheme: (_theme: unknown) => {},
onLogMessage: (listener: (message: string) => void) => {
logListeners.push(listener)
},
showContextMenu: (_options: unknown) => {},
Events: {
trackEvent: (_eventName: string, _data?: unknown) => {}
},
Validation: {
onUpdate: (_callback: (update: unknown) => void) => {},
async getStatus() {
return getValidationUpdate()
},
async validateInstallation(callback: (update: unknown) => void) {
callback(getValidationUpdate())
},
async complete() {
// Only allow completion when the base path is safe
return !validationState.unsafeBasePath
},
dispose: () => {}
},
setBasePath: () => Promise.resolve(true),
reinstall: () => Promise.resolve(),
uv: {
installRequirements: () => Promise.resolve(),
clearCache: () => Promise.resolve(),
resetVenv: () => Promise.resolve()
}
}
}
const ensureElectronAPI = () => {
const globalWindow = window as unknown as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()
}
return globalWindow.electronAPI
}
const MaintenanceView = defineAsyncComponent(async () => {
ensureElectronAPI()
const module = await import('./MaintenanceView.vue')
return module.default
})
const meta: Meta<typeof MaintenanceView> = {
title: 'Desktop/Views/MaintenanceView',
component: MaintenanceView,
parameters: {
layout: 'fullscreen',
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>
export const Default: Story = {
name: 'All tasks OK',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'OK'
validationState.unsafeBasePath = false
validationState.unsafeBasePathReason = null
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}
export const UnsafeBasePathOneDrive: Story = {
name: 'Unsafe base path (OneDrive)',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'error'
validationState.unsafeBasePath = true
validationState.unsafeBasePathReason = 'oneDrive'
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}

View File

@@ -47,6 +47,28 @@
</div>
</div>
<!-- Unsafe migration warning -->
<div v-if="taskStore.unsafeBasePath" class="my-4">
<p class="flex items-start gap-3 text-neutral-300">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
<span>
<strong class="block mb-1">
{{ t('maintenance.unsafeMigration.title') }}
</strong>
<span class="block mb-1">
{{ unsafeReasonText }}
</span>
<span class="block text-sm text-neutral-400">
{{ t('maintenance.unsafeMigration.action') }}
</span>
</span>
</p>
</div>
<!-- Tasks -->
<TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y"
@@ -89,10 +111,10 @@
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
@@ -139,6 +161,27 @@ const filterOptions = ref([
/** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
const unsafeReasonText = computed(() => {
const reason = taskStore.unsafeBasePathReason
if (!reason) {
return t('maintenance.unsafeMigration.generic')
}
if (reason === 'appInstallDir') {
return t('maintenance.unsafeMigration.appInstallDir')
}
if (reason === 'updaterCache') {
return t('maintenance.unsafeMigration.updaterCache')
}
if (reason === 'oneDrive') {
return t('maintenance.unsafeMigration.oneDrive')
}
return t('maintenance.unsafeMigration.generic')
})
/** If valid, leave the validation window. */
const completeValidation = async () => {
const isValid = await electron.Validation.complete()

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.0",
"version": "1.31.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -122,7 +122,7 @@
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",

View File

@@ -157,8 +157,6 @@
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-smoke-300);
/* Code styling colors for help menu*/
@@ -260,8 +258,6 @@
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-charcoal-300);
--dialog-surface: var(--color-neutral-700);
@@ -336,7 +332,6 @@
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-subscription-button-gradient: var(--subscription-button-gradient);
--color-modal-card-button-surface: var(--modal-card-button-surface);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(

119
pnpm-lock.yaml generated
View File

@@ -9,6 +9,9 @@ catalogs:
'@alloc/quick-lru':
specifier: ^5.2.0
version: 5.2.0
'@comfyorg/comfyui-electron-types':
specifier: 0.5.5
version: 0.5.5
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
@@ -266,7 +269,7 @@ catalogs:
version: 3.5.13
vue-component-type-helpers:
specifier: ^3.0.7
version: 3.1.1
version: 3.1.4
vue-eslint-parser:
specifier: ^10.2.0
version: 10.2.0
@@ -309,8 +312,8 @@ importers:
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0
version: 0.4.73-0
specifier: 'catalog:'
version: 0.5.5
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
@@ -671,7 +674,7 @@ importers:
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.1.1
version: 3.1.4
vue-eslint-parser:
specifier: 'catalog:'
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))
@@ -688,8 +691,8 @@ importers:
apps/desktop-ui:
dependencies:
'@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0
version: 0.4.73-0
specifier: 'catalog:'
version: 0.5.5
'@comfyorg/shared-frontend-utils':
specifier: workspace:*
version: link:../../packages/shared-frontend-utils
@@ -1432,8 +1435,8 @@ packages:
'@cacheable/utils@2.0.3':
resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==}
'@comfyorg/comfyui-electron-types@0.4.73-0':
resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==}
'@comfyorg/comfyui-electron-types@0.5.5':
resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -4319,8 +4322,8 @@ packages:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
@@ -6884,8 +6887,8 @@ packages:
resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==}
engines: {node: '>=10'}
resolve@1.22.10:
resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
@@ -6984,6 +6987,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -7701,11 +7709,8 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.2:
resolution: {integrity: sha512-ch3/SKBtxdZq18vsEntiGCdSszCRNfhX5QaTxjSacCAXLlNQRXfXo+ANjoQEYJMsJOJy1/vHF6Tkc4s85MS+zw==}
vue-component-type-helpers@3.1.4:
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -8223,7 +8228,7 @@ snapshots:
'@babel/helper-plugin-utils': 7.27.1
debug: 4.4.3
lodash.debounce: 4.0.8
resolve: 1.22.10
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
@@ -8881,7 +8886,7 @@ snapshots:
'@cacheable/utils@2.0.3': {}
'@comfyorg/comfyui-electron-types@0.4.73-0': {}
'@comfyorg/comfyui-electron-types@0.5.5': {}
'@csstools/color-helpers@5.1.0': {}
@@ -9542,7 +9547,7 @@ snapshots:
jsonc-eslint-parser: 2.4.0
lodash: 4.17.21
parse5: 7.3.0
semver: 7.7.2
semver: 7.7.3
synckit: 0.10.4
vue-eslint-parser: 10.2.0(eslint@9.35.0(jiti@2.4.2))
yaml-eslint-parser: 1.3.0
@@ -9711,7 +9716,7 @@ snapshots:
'@rushstack/ts-command-line': 5.0.3(@types/node@20.14.10)
lodash: 4.17.21
minimatch: 10.0.3
resolve: 1.22.10
resolve: 1.22.11
semver: 7.5.4
source-map: 0.6.1
typescript: 5.8.2
@@ -9723,7 +9728,7 @@ snapshots:
'@microsoft/tsdoc': 0.15.1
ajv: 8.12.0
jju: 1.4.0
resolve: 1.22.10
resolve: 1.22.11
'@microsoft/tsdoc@0.15.1': {}
@@ -9789,7 +9794,7 @@ snapshots:
'@nx/js': 21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)
'@phenomnomnominal/tsquery': 5.0.1(typescript@5.9.2)
detect-port: 1.6.1
semver: 7.7.2
semver: 7.7.3
tree-kill: 1.2.2
tslib: 2.8.1
transitivePeerDependencies:
@@ -9811,7 +9816,7 @@ snapshots:
ignore: 5.3.1
minimatch: 9.0.3
nx: 21.4.1
semver: 7.7.2
semver: 7.7.3
tmp: 0.2.5
tslib: 2.8.1
yargs-parser: 21.1.1
@@ -9821,7 +9826,7 @@ snapshots:
'@nx/devkit': 21.4.1(nx@21.4.1)
'@nx/js': 21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)
eslint: 9.35.0(jiti@2.4.2)
semver: 7.7.2
semver: 7.7.3
tslib: 2.8.1
typescript: 5.8.3
optionalDependencies:
@@ -9862,7 +9867,7 @@ snapshots:
ora: 5.3.0
picocolors: 1.1.1
picomatch: 4.0.2
semver: 7.7.2
semver: 7.7.3
source-map-support: 0.5.19
tinyglobby: 0.2.14
tslib: 2.8.1
@@ -9933,7 +9938,7 @@ snapshots:
'@nx/eslint': 21.4.1(@babel/traverse@7.28.3)(@zkochan/js-yaml@0.0.7)(eslint@9.35.0(jiti@2.4.2))(nx@21.4.1)
'@nx/js': 21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)
'@phenomnomnominal/tsquery': 5.0.1(typescript@5.9.2)
semver: 7.7.2
semver: 7.7.3
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
tslib: 2.8.1
transitivePeerDependencies:
@@ -9957,7 +9962,7 @@ snapshots:
ajv: 8.17.1
enquirer: 2.3.6
picomatch: 4.0.2
semver: 7.7.2
semver: 7.7.3
tsconfig-paths: 4.2.0
tslib: 2.8.1
vite: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)
@@ -9980,7 +9985,7 @@ snapshots:
enquirer: 2.3.6
nx: 21.4.1
picomatch: 4.0.2
semver: 7.7.2
semver: 7.7.3
tslib: 2.8.1
yargs-parser: 21.1.1
transitivePeerDependencies:
@@ -10270,14 +10275,14 @@ snapshots:
fs-extra: 11.3.2
import-lazy: 4.0.0
jju: 1.4.0
resolve: 1.22.10
resolve: 1.22.11
semver: 7.5.4
optionalDependencies:
'@types/node': 20.14.10
'@rushstack/rig-package@0.5.3':
dependencies:
resolve: 1.22.10
resolve: 1.22.11
strip-json-comments: 3.1.1
'@rushstack/terminal@0.16.0(@types/node@20.14.10)':
@@ -10464,7 +10469,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.2
vue-component-type-helpers: 3.1.4
'@swc/helpers@0.5.17':
dependencies:
@@ -10836,7 +10841,7 @@ snapshots:
'@types/react@19.1.9':
dependencies:
csstype: 3.1.3
csstype: 3.2.3
'@types/semver@7.7.0': {}
@@ -10935,7 +10940,7 @@ snapshots:
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.7.2
semver: 7.7.3
ts-api-utils: 2.1.0(typescript@5.9.2)
typescript: 5.9.2
transitivePeerDependencies:
@@ -11275,7 +11280,7 @@ snapshots:
'@vue/reactivity': 3.5.13
'@vue/runtime-core': 3.5.13
'@vue/shared': 3.5.13
csstype: 3.1.3
csstype: 3.2.3
'@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.9.2))':
dependencies:
@@ -11633,7 +11638,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.28.4
cosmiconfig: 7.1.0
resolve: 1.22.10
resolve: 1.22.11
babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.27.1):
dependencies:
@@ -11913,7 +11918,7 @@ snapshots:
dot-prop: 9.0.0
env-paths: 3.0.0
json-schema-typed: 8.0.1
semver: 7.7.2
semver: 7.7.3
uint8array-extras: 1.5.0
confbox@0.1.8: {}
@@ -12013,7 +12018,7 @@ snapshots:
'@asamuzakjp/css-color': 3.2.0
rrweb-cssom: 0.8.0
csstype@3.1.3: {}
csstype@3.2.3: {}
data-urls@5.0.0:
dependencies:
@@ -12214,7 +12219,7 @@ snapshots:
'@one-ini/wasm': 0.1.1
commander: 10.0.1
minimatch: 9.0.1
semver: 7.7.2
semver: 7.7.3
ejs@3.1.10:
dependencies:
@@ -12424,7 +12429,7 @@ snapshots:
eslint-compat-utils@0.6.5(eslint@9.35.0(jiti@2.4.2)):
dependencies:
eslint: 9.35.0(jiti@2.4.2)
semver: 7.7.2
semver: 7.7.3
eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.4.2)):
dependencies:
@@ -12441,7 +12446,7 @@ snapshots:
dependencies:
debug: 3.2.7
is-core-module: 2.16.1
resolve: 1.22.10
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
optional: true
@@ -12483,7 +12488,7 @@ snapshots:
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3
minimatch: 10.0.3
semver: 7.7.2
semver: 7.7.3
stable-hash-x: 0.2.0
unrs-resolver: 1.11.1
optionalDependencies:
@@ -12553,7 +12558,7 @@ snapshots:
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.0
semver: 7.7.2
semver: 7.7.3
vue-eslint-parser: 10.2.0(eslint@9.35.0(jiti@2.4.2))
xml-name-validator: 4.0.0
optionalDependencies:
@@ -13263,7 +13268,7 @@ snapshots:
is-bun-module@2.0.0:
dependencies:
semver: 7.7.2
semver: 7.7.3
is-callable@1.2.7:
optional: true
@@ -13583,7 +13588,7 @@ snapshots:
acorn: 8.15.0
eslint-visitor-keys: 3.4.3
espree: 9.6.1
semver: 7.7.2
semver: 7.7.3
jsonc-parser@3.2.0: {}
@@ -13839,7 +13844,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
semver: 7.7.2
semver: 7.7.3
markdown-it-task-lists@2.1.1: {}
@@ -14326,7 +14331,7 @@ snapshots:
dependencies:
hosted-git-info: 7.0.2
proc-log: 3.0.0
semver: 7.7.2
semver: 7.7.3
validate-npm-package-name: 5.0.1
npm-run-path@4.0.1:
@@ -14375,7 +14380,7 @@ snapshots:
open: 8.4.2
ora: 5.3.0
resolve.exports: 2.0.3
semver: 7.7.2
semver: 7.7.3
string-width: 4.2.3
tar-stream: 2.2.0
tmp: 0.2.5
@@ -14571,7 +14576,7 @@ snapshots:
ky: 1.9.0
registry-auth-token: 5.1.0
registry-url: 6.0.1
semver: 7.7.2
semver: 7.7.3
package-manager-detector@0.2.11:
dependencies:
@@ -14914,7 +14919,7 @@ snapshots:
jstransformer: 1.0.0
pug-error: 2.1.0
pug-walk: 2.0.0
resolve: 1.22.10
resolve: 1.22.11
pug-lexer@5.0.1:
dependencies:
@@ -15159,7 +15164,7 @@ snapshots:
resolve.exports@2.0.3: {}
resolve@1.22.10:
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
@@ -15269,6 +15274,8 @@ snapshots:
semver@7.7.2: {}
semver@7.7.3: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -15419,7 +15426,7 @@ snapshots:
esbuild: 0.25.5
esbuild-register: 3.6.0(esbuild@0.25.5)
recast: 0.23.11
semver: 7.7.2
semver: 7.7.3
ws: 8.18.3
optionalDependencies:
prettier: 3.6.2
@@ -15960,7 +15967,7 @@ snapshots:
is-npm: 6.0.0
latest-version: 9.0.0
pupa: 3.1.0
semver: 7.7.2
semver: 7.7.3
xdg-basedir: 5.1.0
uri-js@4.4.1:
@@ -16161,9 +16168,7 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.1.1: {}
vue-component-type-helpers@3.1.2: {}
vue-component-type-helpers@3.1.4: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:
@@ -16193,7 +16198,7 @@ snapshots:
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.6.0
semver: 7.7.2
semver: 7.7.3
transitivePeerDependencies:
- supports-color

View File

@@ -4,6 +4,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.35.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
@@ -112,7 +113,6 @@ onlyBuiltDependencies:
- '@playwright/browser-chromium'
- '@playwright/browser-firefox'
- '@playwright/browser-webkit'
- '@sentry/cli'
- '@tailwindcss/oxide'
- esbuild
- nx

View File

@@ -158,7 +158,9 @@ const queuePrompt = async (e: Event) => {
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
}
await commandStore.execute(commandId)
}

View File

@@ -10,9 +10,8 @@
v-model:model-value="inputValue"
v-focus
type="text"
size="large"
size="small"
fluid
class="text-2xl"
:pt="{
root: {
onBlur: finishEditing,

View File

@@ -382,7 +382,7 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
@@ -403,8 +403,6 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
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'
@@ -414,34 +412,10 @@ import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose: originalOnClose } = defineProps<{
const { onClose } = defineProps<{
onClose: () => void
}>()
// Track session time for telemetry
const sessionStartTime = ref<number>(0)
const templateWasSelected = ref(false)
onMounted(() => {
sessionStartTime.value = Date.now()
})
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
}
originalOnClose()
}
provide(OnCloseKey, onClose)
// Workflow templates store and composable
@@ -726,7 +700,6 @@ const onLoadWorkflow = async (template: any) => {
template.name,
getEffectiveSourceModule(template)
)
templateWasSelected.value = true
onClose()
} finally {
loadingTemplate.value = null

View File

@@ -61,7 +61,6 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
@@ -93,18 +92,12 @@ const showReport = () => {
const toast = useToast()
const { t } = useI18n()
const systemStatsStore = useSystemStatsStore()
const telemetry = useTelemetry()
const title = computed<string>(
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
)
const showContactSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
await useCommandStore().execute('Comfy.ContactSupport')
}

View File

@@ -15,7 +15,7 @@
<UserCredit text-class="text-3xl font-bold" />
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else-if="isActiveSubscription"
v-else
:label="$t('credits.purchaseCredits')"
:loading="loading"
@click="handlePurchaseCreditsClick"
@@ -92,13 +92,6 @@
icon="pi pi-question-circle"
@click="handleFaqClick"
/>
<Button
:label="$t('subscription.partnerNodesCredits')"
text
severity="secondary"
icon="pi pi-question-circle"
@click="handleOpenPartnerNodesInfo"
/>
<Button
:label="$t('credits.messageSupport')"
text
@@ -123,8 +116,6 @@ import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -141,8 +132,6 @@ const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useSubscription()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
@@ -172,11 +161,6 @@ const handleCreditsHistoryClick = async () => {
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
@@ -184,12 +168,5 @@ const handleFaqClick = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
}
const handleOpenPartnerNodesInfo = () => {
window.open(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
'_blank'
)
}
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

View File

@@ -96,7 +96,6 @@ import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { AuditLog } from '@/services/customerEventsService'
import {
EventType,
@@ -160,9 +159,6 @@ const loadEvents = async () => {
if (response.totalPages) {
pagination.value.totalPages = response.totalPages
}
// Check if a pending top-up has completed
useTelemetry()?.checkForCompletedTopup(response.events)
} else {
error.value = customerEventService.error.value || 'Failed to load events'
}

View File

@@ -27,33 +27,7 @@ const props = defineProps<{
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => {
const src = modelValue.value
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
const tokens: { label: string; url: string }[] = []
const holed = src.replace(
/\[\[([^|\]]+)\|([^\]]+)\]\]/g,
(_m, label, url) => {
tokens.push({ label: String(label), url: String(url) })
return `__LNK${tokens.length - 1}__`
}
)
// Keep current behavior (auto-link bare URLs + \n -> <br>)
let html = nl2br(linkifyHtml(holed))
// Restore placeholders as <a>...</a> (minimal escaping + http default)
html = html.replace(/__LNK(\d+)__/g, (_m, i) => {
const { label, url } = tokens[+i]
const safeHref = url.replace(/"/g, '&quot;')
const safeLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;')
return /^https?:\/\//i.test(url)
? `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`
: safeLabel
})
return html
})
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
let parentNodeId: NodeId | null = null
onMounted(() => {

View File

@@ -138,14 +138,13 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -197,7 +196,6 @@ const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const telemetry = useTelemetry()
// Emits
const emit = defineEmits<{
@@ -209,7 +207,6 @@ const isSubmenuVisible = ref(false)
const submenuRef = ref<HTMLElement | null>(null)
const submenuStyle = ref<CSSProperties>({})
let hoverTimeout: number | null = null
const openedAt = ref<number>(Date.now())
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
@@ -229,7 +226,6 @@ const moreItems = computed<MenuItem[]>(() => {
label: t('helpCenter.desktopUserGuide'),
visible: isElectron(),
action: () => {
trackResourceClick('docs', true)
const docsUrl =
electronAPI().getPlatform() === 'darwin'
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
@@ -285,7 +281,6 @@ const menuItems = computed<MenuItem[]>(() => {
icon: 'pi pi-book',
label: t('helpCenter.docs'),
action: () => {
trackResourceClick('docs', true)
openExternalLink(EXTERNAL_LINKS.DOCS)
emit('close')
}
@@ -296,7 +291,6 @@ const menuItems = computed<MenuItem[]>(() => {
icon: 'pi pi-discord',
label: 'Discord',
action: () => {
trackResourceClick('discord', true)
openExternalLink(EXTERNAL_LINKS.DISCORD)
emit('close')
}
@@ -307,7 +301,6 @@ const menuItems = computed<MenuItem[]>(() => {
icon: 'pi pi-github',
label: t('helpCenter.github'),
action: () => {
trackResourceClick('github', true)
openExternalLink(EXTERNAL_LINKS.GITHUB)
emit('close')
}
@@ -318,7 +311,6 @@ const menuItems = computed<MenuItem[]>(() => {
icon: 'pi pi-question-circle',
label: t('helpCenter.helpFeedback'),
action: () => {
trackResourceClick('help_feedback', false)
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
@@ -334,7 +326,6 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('helpCenter.managerExtension'),
showRedDot: shouldShowManagerRedDot.value,
action: async () => {
trackResourceClick('manager', false)
await useManagerState().openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
@@ -358,23 +349,6 @@ const menuItems = computed<MenuItem[]>(() => {
})
// Utility Functions
const trackResourceClick = (
resourceType:
| 'docs'
| 'discord'
| 'github'
| 'help_feedback'
| 'manager'
| 'release_notes',
isExternal: boolean
): void => {
telemetry?.trackHelpResourceClicked({
resource_type: resourceType,
is_external: isExternal,
source: 'help_center'
})
}
const openExternalLink = (url: string): void => {
window.open(url, '_blank', 'noopener,noreferrer')
}
@@ -530,7 +504,6 @@ const onReinstall = (): void => {
}
const onReleaseClick = (release: ReleaseNote): void => {
trackResourceClick('release_notes', true)
void releaseStore.handleShowChangelog(release.version)
const versionAnchor = formatVersionAnchor(release.version)
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
@@ -539,7 +512,6 @@ const onReleaseClick = (release: ReleaseNote): void => {
}
const onUpdate = (_: ReleaseNote): void => {
trackResourceClick('docs', true)
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
emit('close')
}
@@ -554,16 +526,10 @@ const getChangelogUrl = (): string => {
// Lifecycle
onMounted(async () => {
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
if (!hasReleases.value) {
await releaseStore.fetchReleases()
}
})
onBeforeUnmount(() => {
const timeSpentSeconds = Math.round((Date.now() - openedAt.value) / 1000)
telemetry?.trackHelpCenterClosed({ time_spent_seconds: timeSpentSeconds })
})
</script>
<style scoped>

View File

@@ -1,48 +1,71 @@
<template>
<div
class="widget-expands relative h-full w-full"
class="relative h-full w-full"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<Load3DScene
v-if="node"
ref="load3DSceneRef"
:initialize-load3d="initializeLoad3d"
:cleanup="cleanup"
:loading="loading"
:loading-message="loadingMessage"
:on-model-drop="isPreview ? undefined : handleModelDrop"
:is-preview="isPreview"
:node="node"
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:light-intensity="lightIntensity"
:fov="fov"
:camera-type="cameraType"
:show-preview="showPreview"
:background-image="backgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
:edge-threshold="edgeThreshold"
@material-mode-change="listenMaterialModeChange"
@background-color-change="listenBackgroundColorChange"
@light-intensity-change="listenLightIntensityChange"
@fov-change="listenFOVChange"
@camera-type-change="listenCameraTypeChange"
@show-grid-change="listenShowGridChange"
@show-preview-change="listenShowPreviewChange"
@background-image-change="listenBackgroundImageChange"
@up-direction-change="listenUpDirectionChange"
@edge-threshold-change="listenEdgeThresholdChange"
@recording-status-change="listenRecordingStatusChange"
/>
<Load3DControls
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:show-preview="showPreview"
:light-intensity="lightIntensity"
:show-light-intensity-button="showLightIntensityButton"
:fov="fov"
:show-f-o-v-button="showFOVButton"
:show-preview-button="showPreviewButton"
:camera-type="cameraType"
:has-background-image="hasBackgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
:edge-threshold="edgeThreshold"
@update-background-image="handleBackgroundImageUpdate"
@switch-camera="switchCamera"
@toggle-grid="toggleGrid"
@update-background-color="handleBackgroundColorChange"
@update-light-intensity="handleUpdateLightIntensity"
@toggle-preview="togglePreview"
@update-f-o-v="handleUpdateFOV"
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@export-model="handleExportModel"
/>
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
<Load3DControls
v-model:scene-config="sceneConfig"
v-model:model-config="modelConfig"
v-model:camera-config="cameraConfig"
v-model:light-config="lightConfig"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
/>
<AnimationControls
v-if="animations && animations.length > 0"
v-model:animations="animations"
v-model:playing="playing"
v-model:selected-speed="selectedSpeed"
v-model:selected-animation="selectedAnimation"
/>
</div>
<div
v-if="enable3DViewer && node"
v-if="enable3DViewer"
class="pointer-events-auto absolute top-12 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
<ViewerControls :node="node" />
</div>
<div
v-if="!isPreview"
v-if="showRecordingControls"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-12': !enable3DViewer,
@@ -50,9 +73,10 @@
}"
>
<RecordingControls
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
:node="node"
:is-recording="isRecording"
:has-recording="hasRecording"
:recording-duration="recordingDuration"
@start-recording="handleStartRecording"
@stop-recording="handleStopRecording"
@export-recording="handleExportRecording"
@@ -63,79 +87,250 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraType,
Load3DNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
const { t } = useI18n()
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
}>()
function isComponentWidget(
widget: ComponentWidget<string[]> | SimplifiedWidget
): widget is ComponentWidget<string[]> {
return 'node' in widget && widget.node !== undefined
}
const inputSpec = widget.inputSpec as CustomInputSpec
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
})
}
const node = widget.node
const type = inputSpec.type as Load3DNodeType
const backgroundColor = ref('#000000')
const showGrid = ref(true)
const showPreview = ref(false)
const lightIntensity = ref(5)
const showLightIntensityButton = ref(true)
const fov = ref(75)
const showFOVButton = ref(true)
const cameraType = ref<CameraType>('perspective')
const hasBackgroundImage = ref(false)
const backgroundImage = ref('')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
const {
// configs
sceneConfig,
modelConfig,
cameraConfig,
lightConfig,
// other state
isRecording,
isPreview,
hasRecording,
recordingDuration,
animations,
playing,
selectedSpeed,
selectedAnimation,
loading,
loadingMessage,
// Methods
initializeLoad3d,
handleMouseEnter,
handleMouseLeave,
handleStartRecording,
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)
const isRecording = ref(false)
const hasRecording = ref(false)
const recordingDuration = ref(0)
const showRecordingControls = ref(!inputSpec.isPreview)
const enable3DViewer = computed(() =>
useSettingStore().get('Comfy.Load3D.3DViewerEnable')
)
const showPreviewButton = computed(() => {
return !type.includes('Preview')
})
const handleMouseEnter = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.updateStatusMouseOnScene(true)
}
}
const handleMouseLeave = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.updateStatusMouseOnScene(false)
}
}
const handleStartRecording = async () => {
if (load3DSceneRef.value?.load3d) {
await load3DSceneRef.value.load3d.startRecording()
isRecording.value = true
}
}
const handleStopRecording = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.stopRecording()
isRecording.value = false
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
const handleExportRecording = () => {
if (load3DSceneRef.value?.load3d) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${timestamp}-scene-recording.mp4`
load3DSceneRef.value.load3d.exportRecording(filename)
}
}
const handleClearRecording = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
}
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
showFOVButton.value = cameraType.value === 'perspective'
node.properties['Camera Type'] = cameraType.value
}
const togglePreview = (value: boolean) => {
showPreview.value = value
node.properties['Show Preview'] = showPreview.value
}
const toggleGrid = (value: boolean) => {
showGrid.value = value
node.properties['Show Grid'] = showGrid.value
}
const handleUpdateLightIntensity = (value: number) => {
lightIntensity.value = value
node.properties['Light Intensity'] = lightIntensity.value
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
hasBackgroundImage.value = false
backgroundImage.value = ''
node.properties['Background Image'] = ''
return
}
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
node.properties['Background Image'] = backgroundImage.value
}
const handleUpdateFOV = (value: number) => {
fov.value = value
node.properties['FOV'] = fov.value
}
const handleUpdateEdgeThreshold = (value: number) => {
edgeThreshold.value = value
node.properties['Edge Threshold'] = edgeThreshold.value
}
const handleBackgroundColorChange = (value: string) => {
backgroundColor.value = value
node.properties['Background Color'] = value
}
const handleUpdateUpDirection = (value: UpDirection) => {
upDirection.value = value
node.properties['Up Direction'] = value
}
const handleUpdateMaterialMode = (value: MaterialMode) => {
materialMode.value = value
node.properties['Material Mode'] = value
}
const handleExportModel = async (format: string) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dSceneToExport'))
return
}
try {
await load3DSceneRef.value.load3d.exportModel(format)
} catch (error) {
console.error('Error exporting model:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', {
format: format.toUpperCase()
})
)
}
}
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode
showLightIntensityButton.value = mode === 'original'
}
const listenUpDirectionChange = (value: UpDirection) => {
upDirection.value = value
}
const listenEdgeThresholdChange = (value: number) => {
edgeThreshold.value = value
}
const listenRecordingStatusChange = (value: boolean) => {
isRecording.value = value
if (!value && load3DSceneRef.value?.load3d) {
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
const listenBackgroundColorChange = (value: string) => {
backgroundColor.value = value
}
const listenLightIntensityChange = (value: number) => {
lightIntensity.value = value
}
const listenFOVChange = (value: number) => {
fov.value = value
}
const listenCameraTypeChange = (value: CameraType) => {
cameraType.value = value
showFOVButton.value = cameraType.value === 'perspective'
}
const listenShowGridChange = (value: boolean) => {
showGrid.value = value
}
const listenShowPreviewChange = (value: boolean) => {
showPreview.value = value
}
const listenBackgroundImageChange = (value: string) => {
backgroundImage.value = value
if (backgroundImage.value && backgroundImage.value !== '') {
hasBackgroundImage.value = true
}
}
</script>

View File

@@ -0,0 +1,336 @@
<template>
<div
class="relative h-full w-full"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<Load3DAnimationScene
ref="load3DAnimationSceneRef"
:node="node"
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:light-intensity="lightIntensity"
:fov="fov"
:camera-type="cameraType"
:show-preview="showPreview"
:show-f-o-v-button="showFOVButton"
:show-light-intensity-button="showLightIntensityButton"
:playing="playing"
:selected-speed="selectedSpeed"
:selected-animation="selectedAnimation"
:background-image="backgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
@material-mode-change="listenMaterialModeChange"
@background-color-change="listenBackgroundColorChange"
@light-intensity-change="listenLightIntensityChange"
@fov-change="listenFOVChange"
@camera-type-change="listenCameraTypeChange"
@show-grid-change="listenShowGridChange"
@show-preview-change="listenShowPreviewChange"
@background-image-change="listenBackgroundImageChange"
@animation-list-change="animationListChange"
@up-direction-change="listenUpDirectionChange"
@recording-status-change="listenRecordingStatusChange"
/>
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
<Load3DControls
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:show-preview="showPreview"
:light-intensity="lightIntensity"
:show-light-intensity-button="showLightIntensityButton"
:fov="fov"
:show-f-o-v-button="showFOVButton"
:show-preview-button="showPreviewButton"
:camera-type="cameraType"
:has-background-image="hasBackgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
@update-background-image="handleBackgroundImageUpdate"
@switch-camera="switchCamera"
@toggle-grid="toggleGrid"
@update-background-color="handleBackgroundColorChange"
@update-light-intensity="handleUpdateLightIntensity"
@toggle-preview="togglePreview"
@update-f-o-v="handleUpdateFOV"
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
/>
<Load3DAnimationControls
:animations="animations"
:playing="playing"
@toggle-play="togglePlay"
@speed-change="speedChange"
@animation-change="animationChange"
/>
</div>
<div
v-if="showRecordingControls"
class="pointer-events-auto absolute top-12 right-2 z-20"
>
<RecordingControls
:node="node"
:is-recording="isRecording"
:has-recording="hasRecording"
:recording-duration="recordingDuration"
@start-recording="handleStartRecording"
@stop-recording="handleStopRecording"
@export-recording="handleExportRecording"
@clear-recording="handleClearRecording"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue'
import Load3DAnimationScene from '@/components/load3d/Load3DAnimationScene.vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
CameraType,
Load3DAnimationNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
}>()
const inputSpec = widget.inputSpec as CustomInputSpec
const node = widget.node
const type = inputSpec.type as Load3DAnimationNodeType
const backgroundColor = ref('#000000')
const showGrid = ref(true)
const showPreview = ref(false)
const lightIntensity = ref(5)
const showLightIntensityButton = ref(true)
const fov = ref(75)
const showFOVButton = ref(true)
const cameraType = ref<'perspective' | 'orthographic'>('perspective')
const hasBackgroundImage = ref(false)
const animations = ref<AnimationItem[]>([])
const playing = ref(false)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const backgroundImage = ref('')
const isRecording = ref(false)
const hasRecording = ref(false)
const recordingDuration = ref(0)
const showRecordingControls = ref(!inputSpec.isPreview)
const showPreviewButton = computed(() => {
return !type.includes('Preview')
})
const load3DAnimationSceneRef = ref<InstanceType<
typeof Load3DAnimationScene
> | null>(null)
const handleMouseEnter = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.updateStatusMouseOnScene(true)
}
}
const handleMouseLeave = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.updateStatusMouseOnScene(false)
}
}
const handleStartRecording = async () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
await sceneRef.load3d.startRecording()
isRecording.value = true
}
}
const handleStopRecording = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.stopRecording()
isRecording.value = false
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
const handleExportRecording = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${timestamp}-animation-recording.mp4`
sceneRef.load3d.exportRecording(filename)
}
}
const handleClearRecording = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
}
}
const listenRecordingStatusChange = (value: boolean) => {
isRecording.value = value
if (!value) {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
showFOVButton.value = cameraType.value === 'perspective'
node.properties['Camera Type'] = cameraType.value
}
const togglePreview = (value: boolean) => {
showPreview.value = value
node.properties['Show Preview'] = showPreview.value
}
const toggleGrid = (value: boolean) => {
showGrid.value = value
node.properties['Show Grid'] = showGrid.value
}
const handleUpdateLightIntensity = (value: number) => {
lightIntensity.value = value
node.properties['Light Intensity'] = lightIntensity.value
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
hasBackgroundImage.value = false
backgroundImage.value = ''
node.properties['Background Image'] = ''
return
}
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
node.properties['Background Image'] = backgroundImage.value
}
const handleUpdateFOV = (value: number) => {
fov.value = value
node.properties['FOV'] = fov.value
}
const materialMode = ref<MaterialMode>('original')
const upDirection = ref<UpDirection>('original')
const handleUpdateUpDirection = (value: UpDirection) => {
upDirection.value = value
node.properties['Up Direction'] = value
}
const handleUpdateMaterialMode = (value: MaterialMode) => {
materialMode.value = value
node.properties['Material Mode'] = value
}
const handleBackgroundColorChange = (value: string) => {
backgroundColor.value = value
node.properties['Background Color'] = value
}
const togglePlay = (value: boolean) => {
playing.value = value
}
const speedChange = (value: number) => {
selectedSpeed.value = value
}
const animationChange = (value: number) => {
selectedAnimation.value = value
}
const animationListChange = (value: any) => {
animations.value = value
}
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode
showLightIntensityButton.value = mode === 'original'
}
const listenUpDirectionChange = (value: UpDirection) => {
upDirection.value = value
}
const listenBackgroundColorChange = (value: string) => {
backgroundColor.value = value
}
const listenLightIntensityChange = (value: number) => {
lightIntensity.value = value
}
const listenFOVChange = (value: number) => {
fov.value = value
}
const listenCameraTypeChange = (value: CameraType) => {
cameraType.value = value
showFOVButton.value = cameraType.value === 'perspective'
}
const listenShowGridChange = (value: boolean) => {
showGrid.value = value
}
const listenShowPreviewChange = (value: boolean) => {
showPreview.value = value
}
const listenBackgroundImageChange = (value: string) => {
backgroundImage.value = value
if (backgroundImage.value && backgroundImage.value !== '') {
hasBackgroundImage.value = true
}
}
</script>

View File

@@ -15,6 +15,7 @@
option-label="name"
option-value="value"
class="w-24"
@change="speedChange"
/>
<Select
@@ -23,6 +24,7 @@
option-label="name"
option-value="index"
class="w-32"
@change="animationChange"
/>
</div>
</template>
@@ -30,13 +32,23 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { ref, watch } from 'vue'
type Animation = { name: string; index: number }
const props = defineProps<{
animations: Array<{ name: string; index: number }>
playing: boolean
}>()
const animations = defineModel<Animation[]>('animations')
const playing = defineModel<boolean>('playing')
const selectedSpeed = defineModel<number>('selectedSpeed')
const selectedAnimation = defineModel<number>('selectedAnimation')
const emit = defineEmits<{
(e: 'togglePlay', value: boolean): void
(e: 'speedChange', value: number): void
(e: 'animationChange', value: number): void
}>()
const animations = ref(props.animations)
const playing = ref(props.playing)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const speedOptions = [
{ name: '0.1x', value: 0.1 },
@@ -46,7 +58,24 @@ const speedOptions = [
{ name: '2x', value: 2 }
]
watch(
() => props.animations,
(newVal) => {
animations.value = newVal
}
)
const togglePlay = () => {
playing.value = !playing.value
emit('togglePlay', playing.value)
}
const speedChange = () => {
emit('speedChange', selectedSpeed.value)
}
const animationChange = () => {
emit('animationChange', selectedAnimation.value)
}
</script>

View File

@@ -0,0 +1,208 @@
<template>
<Load3DScene
ref="load3DSceneRef"
:node="node"
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:light-intensity="lightIntensity"
:fov="fov"
:camera-type="cameraType"
:show-preview="showPreview"
:extra-listeners="animationListeners"
:background-image="backgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
@material-mode-change="listenMaterialModeChange"
@background-color-change="listenBackgroundColorChange"
@light-intensity-change="listenLightIntensityChange"
@fov-change="listenFOVChange"
@camera-type-change="listenCameraTypeChange"
@show-grid-change="listenShowGridChange"
@show-preview-change="listenShowPreviewChange"
@recording-status-change="listenRecordingStatusChange"
/>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import type Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type {
CameraType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const props = defineProps<{
node: any
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
fov: number
cameraType: CameraType
showPreview: boolean
materialMode: MaterialMode
upDirection: UpDirection
showFOVButton: boolean
showLightIntensityButton: boolean
playing: boolean
selectedSpeed: number
selectedAnimation: number
backgroundImage: string
}>()
const node = ref(props.node)
const backgroundColor = ref(props.backgroundColor)
const showPreview = ref(props.showPreview)
const fov = ref(props.fov)
const lightIntensity = ref(props.lightIntensity)
const cameraType = ref(props.cameraType)
const showGrid = ref(props.showGrid)
const upDirection = ref(props.upDirection)
const materialMode = ref(props.materialMode)
const showFOVButton = ref(props.showFOVButton)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
watch(
() => props.cameraType,
(newValue) => {
cameraType.value = newValue
}
)
watch(
() => props.showGrid,
(newValue) => {
showGrid.value = newValue
}
)
watch(
() => props.backgroundColor,
(newValue) => {
backgroundColor.value = newValue
}
)
watch(
() => props.lightIntensity,
(newValue) => {
lightIntensity.value = newValue
}
)
watch(
() => props.fov,
(newValue) => {
fov.value = newValue
}
)
watch(
() => props.upDirection,
(newValue) => {
upDirection.value = newValue
}
)
watch(
() => props.materialMode,
(newValue) => {
materialMode.value = newValue
}
)
watch(
() => props.showPreview,
(newValue) => {
showPreview.value = newValue
}
)
watch(
() => props.playing,
(newValue) => {
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.toggleAnimation(newValue)
}
)
watch(
() => props.selectedSpeed,
(newValue) => {
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.setAnimationSpeed(newValue)
}
)
watch(
() => props.selectedAnimation,
(newValue) => {
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.updateSelectedAnimation(newValue)
}
)
const emit = defineEmits<{
(e: 'animationListChange', animationList: string): void
(e: 'materialModeChange', materialMode: MaterialMode): void
(e: 'backgroundColorChange', color: string): void
(e: 'lightIntensityChange', lightIntensity: number): void
(e: 'fovChange', fov: number): void
(e: 'cameraTypeChange', cameraType: CameraType): void
(e: 'showGridChange', showGrid: boolean): void
(e: 'showPreviewChange', showPreview: boolean): void
(e: 'upDirectionChange', direction: UpDirection): void
(e: 'recording-status-change', status: boolean): void
}>()
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode
showLightIntensityButton.value = mode === 'original'
}
const listenBackgroundColorChange = (value: string) => {
backgroundColor.value = value
}
const listenLightIntensityChange = (value: number) => {
lightIntensity.value = value
}
const listenFOVChange = (value: number) => {
fov.value = value
}
const listenCameraTypeChange = (value: CameraType) => {
cameraType.value = value
showFOVButton.value = cameraType.value === 'perspective'
}
const listenShowGridChange = (value: boolean) => {
showGrid.value = value
}
const listenShowPreviewChange = (value: boolean) => {
showPreview.value = value
}
const listenRecordingStatusChange = (value: boolean) => {
emit('recording-status-change', value)
}
const animationListeners = {
animationListChange: (newValue: any) => {
emit('animationListChange', newValue)
}
}
defineExpose({
load3DSceneRef
})
</script>

View File

@@ -1,10 +1,6 @@
<template>
<div
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@wheel.stop
>
<div class="show-menu relative">
<Button class="p-button-rounded p-button-text" @click="toggleMenu">
@@ -24,9 +20,7 @@
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)" />
<span class="whitespace-nowrap text-white">{{
$t(categoryLabels[category])
}}</span>
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
</Button>
</div>
</div>
@@ -34,47 +28,71 @@
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
<SceneControls
v-if="showSceneControls"
v-if="activeCategory === 'scene'"
ref="sceneControlsRef"
v-model:show-grid="sceneConfig!.showGrid"
v-model:background-color="sceneConfig!.backgroundColor"
v-model:background-image="sceneConfig!.backgroundImage"
:background-color="backgroundColor"
:show-grid="showGrid"
:has-background-image="hasBackgroundImage"
@toggle-grid="handleToggleGrid"
@update-background-color="handleBackgroundColorChange"
@update-background-image="handleBackgroundImageUpdate"
/>
<ModelControls
v-if="showModelControls"
v-if="activeCategory === 'model'"
ref="modelControlsRef"
v-model:material-mode="modelConfig!.materialMode"
v-model:up-direction="modelConfig!.upDirection"
:input-spec="inputSpec"
:up-direction="upDirection"
:material-mode="materialMode"
:edge-threshold="edgeThreshold"
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
/>
<CameraControls
v-if="showCameraControls"
v-if="activeCategory === 'camera'"
ref="cameraControlsRef"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
:camera-type="cameraType"
:fov="fov"
:show-f-o-v-button="showFOVButton"
@switch-camera="switchCamera"
@update-f-o-v="handleUpdateFOV"
/>
<LightControls
v-if="showLightControls"
v-if="activeCategory === 'light'"
ref="lightControlsRef"
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
:light-intensity="lightIntensity"
:show-light-intensity-button="showLightIntensityButton"
@update-light-intensity="handleUpdateLightIntensity"
/>
<ExportControls
v-if="showExportControls"
v-if="activeCategory === 'export'"
ref="exportControlsRef"
@export-model="handleExportModel"
/>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
<i
v-tooltip.right="{ value: t('load3d.previewOutput'), showDelay: 300 }"
:class="[
'pi',
showPreview ? 'pi-eye' : 'pi-eye-slash',
'text-lg text-white'
]"
/>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
@@ -82,16 +100,31 @@ import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
import type {
CameraConfig,
LightConfig,
ModelConfig,
SceneConfig
CameraType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
const modelConfig = defineModel<ModelConfig>('modelConfig')
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
const lightConfig = defineModel<LightConfig>('lightConfig')
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
showPreview: boolean
lightIntensity: number
showLightIntensityButton: boolean
fov: number
showFOVButton: boolean
showPreviewButton: boolean
cameraType: CameraType
hasBackgroundImage?: boolean
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold?: number
}>()
const isMenuOpen = ref(false)
const activeCategory = ref<string>('scene')
@@ -104,25 +137,14 @@ const categoryLabels: Record<string, string> = {
}
const availableCategories = computed(() => {
return ['scene', 'model', 'camera', 'light', 'export']
})
const baseCategories = ['scene', 'model', 'camera', 'light']
const showSceneControls = computed(
() => activeCategory.value === 'scene' && !!sceneConfig.value
)
const showModelControls = computed(
() => activeCategory.value === 'model' && !!modelConfig.value
)
const showCameraControls = computed(
() => activeCategory.value === 'camera' && !!cameraConfig.value
)
const showLightControls = computed(
() =>
activeCategory.value === 'light' &&
!!lightConfig.value &&
!!modelConfig.value
)
const showExportControls = computed(() => activeCategory.value === 'export')
if (!props.inputSpec.isAnimation) {
return [...baseCategories, 'export']
}
return baseCategories
})
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
@@ -146,14 +168,73 @@ const getCategoryIcon = (category: string) => {
}
const emit = defineEmits<{
(e: 'switchCamera'): void
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'updateLightIntensity', value: number): void
(e: 'updateFOV', value: number): void
(e: 'togglePreview', value: boolean): void
(e: 'updateBackgroundImage', file: File | null): void
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'exportModel', format: string): void
}>()
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const showPreview = ref(props.showPreview)
const lightIntensity = ref(props.lightIntensity)
const upDirection = ref(props.upDirection || 'original')
const materialMode = ref(props.materialMode || 'original')
const showLightIntensityButton = ref(props.showLightIntensityButton)
const fov = ref(props.fov)
const showFOVButton = ref(props.showFOVButton)
const showPreviewButton = ref(props.showPreviewButton)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const edgeThreshold = ref(props.edgeThreshold)
const switchCamera = () => {
emit('switchCamera')
}
const togglePreview = () => {
showPreview.value = !showPreview.value
emit('togglePreview', showPreview.value)
}
const handleToggleGrid = (value: boolean) => {
emit('toggleGrid', value)
}
const handleBackgroundColorChange = (value: string) => {
emit('updateBackgroundColor', value)
}
const handleBackgroundImageUpdate = (file: File | null) => {
emit('updateBackgroundImage', file)
}
const handleUpdateUpDirection = (direction: UpDirection) => {
emit('updateUpDirection', direction)
}
const handleUpdateMaterialMode = (mode: MaterialMode) => {
emit('updateMaterialMode', mode)
}
const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}
const handleUpdateFOV = (value: number) => {
emit('updateFOV', value)
}
const handleExportModel = (format: string) => {
emit('exportModel', format)
}
@@ -166,6 +247,101 @@ const closeSlider = (e: MouseEvent) => {
}
}
watch(
() => props.upDirection,
(newValue) => {
if (newValue) {
upDirection.value = newValue
}
}
)
watch(
() => props.backgroundColor,
(newValue) => {
backgroundColor.value = newValue
}
)
watch(
() => props.fov,
(newValue) => {
fov.value = newValue
}
)
watch(
() => props.lightIntensity,
(newValue) => {
lightIntensity.value = newValue
}
)
watch(
() => props.showFOVButton,
(newValue) => {
showFOVButton.value = newValue
}
)
watch(
() => props.showLightIntensityButton,
(newValue) => {
showLightIntensityButton.value = newValue
}
)
watch(
() => props.upDirection,
(newValue) => {
upDirection.value = newValue
}
)
watch(
() => props.materialMode,
(newValue) => {
materialMode.value = newValue
}
)
watch(
() => props.showPreviewButton,
(newValue) => {
showPreviewButton.value = newValue
}
)
watch(
() => props.showPreview,
(newValue) => {
showPreview.value = newValue
}
)
watch(
() => props.hasBackgroundImage,
(newValue) => {
hasBackgroundImage.value = newValue
}
)
watch(
() => props.materialMode,
(newValue) => {
if (newValue) {
materialMode.value = newValue
}
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
edgeThreshold.value = newValue
}
)
onMounted(() => {
document.addEventListener('click', closeSlider)
})

View File

@@ -1,72 +1,238 @@
<template>
<div
ref="container"
class="relative h-full w-full"
data-capture-wheel="true"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@mousedown.stop
@mousemove.stop
@mouseup.stop
@contextmenu.stop.prevent
@dragover.prevent.stop="handleDragOver"
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
>
<LoadingOverlay
ref="loadingOverlayRef"
:loading="loading"
:loading-message="loadingMessage"
/>
<div
v-if="!isPreview && isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div
class="rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/20 px-6 py-4 text-lg font-medium text-blue-100"
>
{{ dragMessage }}
</div>
</div>
<div ref="container" class="relative h-full w-full">
<LoadingOverlay ref="loadingOverlayRef" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import type Load3d from '@/extensions/core/load3d/Load3d'
import type Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type {
CameraType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useLoad3dService } from '@/services/load3dService'
const props = defineProps<{
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>
cleanup: () => void
loading: boolean
loadingMessage: string
onModelDrop?: (file: File) => void | Promise<void>
isPreview: boolean
node: LGraphNode
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
fov: number
cameraType: CameraType
showPreview: boolean
backgroundImage: string
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold?: number
extraListeners?: Record<string, (value: any) => void>
}>()
const container = ref<HTMLElement | null>(null)
const node = ref(props.node)
const load3d = ref<Load3d | Load3dAnimation | null>(null)
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {
if (props.onModelDrop) {
await props.onModelDrop(file)
}
},
disabled: computed(() => props.isPreview)
const eventConfig = {
materialModeChange: (value: string) =>
emit('materialModeChange', value as MaterialMode),
backgroundColorChange: (value: string) =>
emit('backgroundColorChange', value),
lightIntensityChange: (value: number) => emit('lightIntensityChange', value),
fovChange: (value: number) => emit('fovChange', value),
cameraTypeChange: (value: string) =>
emit('cameraTypeChange', value as CameraType),
showGridChange: (value: boolean) => emit('showGridChange', value),
showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
backgroundImageChange: (value: string) =>
emit('backgroundImageChange', value),
backgroundImageLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.loadingBackgroundImage')),
backgroundImageLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
upDirectionChange: (value: string) =>
emit('upDirectionChange', value as UpDirection),
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
modelLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.loadingModel')),
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
materialLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
exportLoadingStart: (message: string) => {
loadingOverlayRef.value?.startLoading(message || t('load3d.exportingModel'))
},
exportLoadingEnd: () => {
loadingOverlayRef.value?.endLoading()
},
recordingStatusChange: (value: boolean) =>
emit('recordingStatusChange', value)
} as const
watch(
() => props.showPreview,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.togglePreview(newValue)
}
}
)
watch(
() => props.cameraType,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleCamera(newValue)
}
}
)
watch(
() => props.fov,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setFOV(newValue)
}
}
)
watch(
() => props.lightIntensity,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setLightIntensity(newValue)
}
}
)
watch(
() => props.showGrid,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleGrid(newValue)
}
}
)
watch(
() => props.backgroundColor,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(newValue)
}
}
)
watch(
() => props.backgroundImage,
async (newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
await rawLoad3d.setBackgroundImage(newValue)
}
}
)
watch(
() => props.upDirection,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setUpDirection(newValue)
}
}
)
watch(
() => props.materialMode,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setMaterialMode(newValue)
}
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
if (load3d.value && newValue) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setEdgeThreshold(newValue)
}
}
)
const emit = defineEmits<{
(e: 'materialModeChange', materialMode: MaterialMode): void
(e: 'backgroundColorChange', color: string): void
(e: 'lightIntensityChange', lightIntensity: number): void
(e: 'fovChange', fov: number): void
(e: 'cameraTypeChange', cameraType: CameraType): void
(e: 'showGridChange', showGrid: boolean): void
(e: 'showPreviewChange', showPreview: boolean): void
(e: 'backgroundImageChange', backgroundImage: string): void
(e: 'upDirectionChange', upDirection: UpDirection): void
(e: 'edgeThresholdChange', threshold: number): void
(e: 'recordingStatusChange', status: boolean): void
}>()
const handleEvents = (action: 'add' | 'remove') => {
if (!load3d.value) return
Object.entries(eventConfig).forEach(([event, handler]) => {
const method = `${action}EventListener` as const
load3d.value?.[method](event, handler)
})
if (props.extraListeners) {
Object.entries(props.extraListeners).forEach(([event, handler]) => {
const method = `${action}EventListener` as const
load3d.value?.[method](event, handler)
})
}
}
onMounted(() => {
if (container.value) {
void props.initializeLoad3d(container.value)
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
container.value,
props.inputSpec
)
}
handleEvents('add')
})
onUnmounted(() => {
props.cleanup()
handleEvents('remove')
useLoad3dService().removeLoad3d(node.value as LGraphNode)
})
defineExpose({
load3d
})
</script>

View File

@@ -11,20 +11,7 @@
ref="containerRef"
class="absolute h-full w-full"
@resize="viewer.handleResize"
@dragover.prevent.stop="handleDragOver"
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
/>
<div
v-if="isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div
class="rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/20 px-6 py-4 text-lg font-medium text-blue-100"
>
{{ dragMessage }}
</div>
</div>
</div>
<div class="flex w-72 flex-col">
@@ -88,7 +75,6 @@ import ExportControls from '@/components/load3d/controls/viewer/ViewerExportCont
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
@@ -106,14 +92,6 @@ const mutationObserver = ref<MutationObserver | null>(null)
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {
await viewer.handleModelDrop(file)
},
disabled: viewer.isPreview
})
onMounted(async () => {
const source = useLoad3dService().getLoad3d(props.node)
if (source && containerRef.value) {

View File

@@ -1,8 +1,8 @@
<template>
<Transition name="fade">
<div
v-if="loading"
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
v-if="modelLoading"
class="absolute inset-0 z-50 flex items-center justify-center bg-black/50"
>
<div class="flex flex-col items-center">
<div class="spinner" />
@@ -15,10 +15,29 @@
</template>
<script setup lang="ts">
defineProps<{
loading: boolean
loadingMessage: string
}>()
import { nextTick, ref } from 'vue'
import { t } from '@/i18n'
const modelLoading = ref(false)
const loadingMessage = ref('')
const startLoading = async (message?: string) => {
loadingMessage.value = message || t('load3d.loadingModel')
modelLoading.value = true
await nextTick()
}
const endLoading = async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
modelLoading.value = false
}
defineExpose({
startLoading,
endLoading
})
</script>
<style scoped>

View File

@@ -3,7 +3,7 @@
<Button class="p-button-rounded p-button-text" @click="switchCamera">
<i
v-tooltip.right="{
value: $t('load3d.switchCamera'),
value: t('load3d.switchCamera'),
showDelay: 300
}"
:class="['pi', getCameraIcon, 'text-lg text-white']"
@@ -12,7 +12,7 @@
<div v-if="showFOVButton" class="show-fov relative">
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
<i
v-tooltip.right="{ value: $t('load3d.fov'), showDelay: 300 }"
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
class="pi pi-expand text-lg text-white"
/>
</Button>
@@ -21,37 +21,83 @@
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
style="width: 150px"
>
<Slider v-model="fov" class="w-full" :min="10" :max="150" :step="1" />
<Slider
v-model="fov"
class="w-full"
:min="10"
:max="150"
:step="1"
@change="updateFOV"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
cameraType: CameraType
fov: number
showFOVButton: boolean
}>()
const emit = defineEmits<{
(e: 'switchCamera'): void
(e: 'updateFOV', value: number): void
}>()
const cameraType = ref(props.cameraType)
const fov = ref(props.fov)
const showFOVButton = ref(props.showFOVButton)
const showFOV = ref(false)
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
const getCameraIcon = computed(() => {
return cameraType.value === 'perspective' ? 'pi-camera' : 'pi-camera'
})
watch(
() => props.fov,
(newValue) => {
fov.value = newValue
}
)
watch(
() => props.showFOVButton,
(newValue) => {
showFOVButton.value = newValue
}
)
watch(
() => props.cameraType,
(newValue) => {
cameraType.value = newValue
}
)
const switchCamera = () => {
emit('switchCamera')
}
const toggleFOV = () => {
showFOV.value = !showFOV.value
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
const updateFOV = () => {
emit('updateFOV', fov.value)
}
const getCameraIcon = computed(() => {
return props.cameraType === 'perspective' ? 'pi-camera' : 'pi-camera'
})
const closeCameraSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -7,7 +7,7 @@
>
<i
v-tooltip.right="{
value: $t('load3d.exportModel'),
value: t('load3d.exportModel'),
showDelay: 300
}"
class="pi pi-download text-lg text-white"
@@ -33,9 +33,14 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()

View File

@@ -7,7 +7,7 @@
>
<i
v-tooltip.right="{
value: $t('load3d.lightIntensity'),
value: t('load3d.lightIntensity'),
showDelay: 300
}"
class="pi pi-sun text-lg text-white"
@@ -24,6 +24,7 @@
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
@change="updateLightIntensity"
/>
</div>
</div>
@@ -31,19 +32,27 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
const materialMode = defineModel<MaterialMode>('materialMode')
const vTooltip = Tooltip
const showLightIntensityButton = computed(
() => materialMode.value === 'original'
)
const props = defineProps<{
lightIntensity: number
showLightIntensityButton: boolean
}>()
const emit = defineEmits<{
(e: 'updateLightIntensity', value: number): void
}>()
const lightIntensity = ref(props.lightIntensity)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const showLightIntensity = ref(false)
const lightIntensityMaximum = useSettingStore().get(
@@ -56,10 +65,28 @@ const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
watch(
() => props.lightIntensity,
(newValue) => {
lightIntensity.value = newValue
}
)
watch(
() => props.showLightIntensityButton,
(newValue) => {
showLightIntensityButton.value = newValue
}
)
const toggleLightIntensity = () => {
showLightIntensity.value = !showLightIntensity.value
}
const updateLightIntensity = () => {
emit('updateLightIntensity', lightIntensity.value)
}
const closeLightSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -22,7 +22,7 @@
:class="{ 'bg-blue-500': upDirection === direction }"
@click="selectUpDirection(direction)"
>
{{ direction.toUpperCase() }}
{{ formatOption(direction) }}
</Button>
</div>
</div>
@@ -49,7 +49,7 @@
<Button
v-for="mode in materialModes"
:key="mode"
class="p-button-text whitespace-nowrap text-white"
class="p-button-text text-white"
:class="{ 'bg-blue-500': materialMode === mode }"
@click="selectMaterialMode(mode)"
>
@@ -58,24 +58,75 @@
</div>
</div>
</div>
<div v-if="materialMode === 'lineart'" class="show-edge-threshold relative">
<Button
class="p-button-rounded p-button-text"
@click="toggleEdgeThreshold"
>
<i
v-tooltip.right="{
value: t('load3d.edgeThreshold'),
showDelay: 300
}"
class="pi pi-sliders-h text-lg text-white"
/>
</Button>
<div
v-show="showEdgeThreshold"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
style="width: 150px"
>
<label class="mb-1 block text-xs text-white"
>{{ t('load3d.edgeThreshold') }}: {{ edgeThreshold }}°</label
>
<Slider
v-model="edgeThreshold"
class="w-full"
:min="0"
:max="120"
:step="1"
@change="updateEdgeThreshold"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type {
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const materialMode = defineModel<MaterialMode>('materialMode')
const upDirection = defineModel<UpDirection>('upDirection')
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold?: number
}>()
const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
}>()
const upDirection = ref(props.upDirection || 'original')
const materialMode = ref(props.materialMode || 'original')
const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const upDirections: UpDirection[] = [
'original',
@@ -95,26 +146,65 @@ const materialModes = computed(() => {
//'depth' disable for now
]
if (!props.inputSpec.isAnimation && !props.inputSpec.isPreview) {
modes.push('lineart')
}
return modes
})
watch(
() => props.upDirection,
(newValue) => {
if (newValue) {
upDirection.value = newValue
}
}
)
watch(
() => props.materialMode,
(newValue) => {
if (newValue) {
materialMode.value = newValue
}
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
// @ts-expect-error fixme ts strict error
edgeThreshold.value = newValue
}
)
const toggleUpDirection = () => {
showUpDirection.value = !showUpDirection.value
showMaterialMode.value = false
showEdgeThreshold.value = false
}
const selectUpDirection = (direction: UpDirection) => {
upDirection.value = direction
emit('updateUpDirection', direction)
showUpDirection.value = false
}
const formatOption = (option: string) => {
if (option === 'original') return 'Original'
return option.toUpperCase()
}
const toggleMaterialMode = () => {
showMaterialMode.value = !showMaterialMode.value
showUpDirection.value = false
showEdgeThreshold.value = false
}
const selectMaterialMode = (mode: MaterialMode) => {
materialMode.value = mode
emit('updateMaterialMode', mode)
showMaterialMode.value = false
}
@@ -122,6 +212,16 @@ const formatMaterialMode = (mode: MaterialMode) => {
return t(`load3d.materialModes.${mode}`)
}
const toggleEdgeThreshold = () => {
showEdgeThreshold.value = !showEdgeThreshold.value
showUpDirection.value = false
showMaterialMode.value = false
}
const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
@@ -132,6 +232,10 @@ const closeSceneSlider = (e: MouseEvent) => {
if (!target.closest('.show-material-mode')) {
showMaterialMode.value = false
}
if (!target.closest('.show-edge-threshold')) {
showEdgeThreshold.value = false
}
}
onMounted(() => {

View File

@@ -1,6 +1,18 @@
<template>
<div class="relative rounded-lg bg-smoke-700/30">
<div class="flex flex-col gap-2">
<Button
class="p-button-rounded p-button-text"
@click="resizeNodeMatchOutput"
>
<i
v-tooltip.right="{
value: t('load3d.resizeNodeMatchOutput'),
showDelay: 300
}"
class="pi pi-window-maximize text-lg text-white"
/>
</Button>
<Button
class="p-button-rounded p-button-text"
:class="{
@@ -12,8 +24,8 @@
<i
v-tooltip.right="{
value: isRecording
? $t('load3d.stopRecording')
: $t('load3d.startRecording'),
? t('load3d.stopRecording')
: t('load3d.startRecording'),
showDelay: 300
}"
:class="[
@@ -27,11 +39,11 @@
<Button
v-if="hasRecording && !isRecording"
class="p-button-rounded p-button-text"
@click="handleExportRecording"
@click="exportRecording"
>
<i
v-tooltip.right="{
value: $t('load3d.exportRecording'),
value: t('load3d.exportRecording'),
showDelay: 300
}"
class="pi pi-download text-lg text-white"
@@ -41,11 +53,11 @@
<Button
v-if="hasRecording && !isRecording"
class="p-button-rounded p-button-text"
@click="handleClearRecording"
@click="clearRecording"
>
<i
v-tooltip.right="{
value: $t('load3d.clearRecording'),
value: t('load3d.clearRecording'),
showDelay: 300
}"
class="pi pi-trash text-lg text-white"
@@ -53,7 +65,7 @@
</Button>
<div
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
v-if="recordingDuration > 0 && !isRecording"
class="mt-1 text-center text-xs text-white"
>
{{ formatDuration(recordingDuration) }}
@@ -63,11 +75,21 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
const hasRecording = defineModel<boolean>('hasRecording')
const isRecording = defineModel<boolean>('isRecording')
const recordingDuration = defineModel<number>('recordingDuration')
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLoad3dService } from '@/services/load3dService'
const vTooltip = Tooltip
const { hasRecording, isRecording, node, recordingDuration } = defineProps<{
hasRecording: boolean
isRecording: boolean
node: LGraphNode
recordingDuration: number
}>()
const emit = defineEmits<{
(e: 'startRecording'): void
@@ -76,19 +98,49 @@ const emit = defineEmits<{
(e: 'clearRecording'): void
}>()
const resizeNodeMatchOutput = () => {
const outputWidth = node.widgets?.find((w) => w.name === 'width')
const outputHeight = node.widgets?.find((w) => w.name === 'height')
if (outputWidth && outputHeight && outputHeight.value && outputWidth.value) {
const [oldWidth, oldHeight] = node.size
const scene = node.widgets?.find((w) => w.name === 'image')
const sceneHeight = scene?.computedHeight
if (sceneHeight) {
const sceneWidth = oldWidth - 20
const outputRatio = Number(outputHeight.value) / Number(outputWidth.value)
const expectSceneHeight = sceneWidth * outputRatio
node.setSize([oldWidth, oldHeight + (expectSceneHeight - sceneHeight)])
node.graph?.setDirtyCanvas(true, true)
const load3d = useLoad3dService().getLoad3d(node as LGraphNode)
if (load3d) {
load3d.refreshViewport()
}
}
}
}
const toggleRecording = () => {
if (isRecording.value) {
if (isRecording) {
emit('stopRecording')
} else {
emit('startRecording')
}
}
const handleExportRecording = () => {
const exportRecording = () => {
emit('exportRecording')
}
const handleClearRecording = () => {
const clearRecording = () => {
emit('clearRecording')
}

View File

@@ -6,7 +6,7 @@
@click="toggleGrid"
>
<i
v-tooltip.right="{ value: $t('load3d.showGrid'), showDelay: 300 }"
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
class="pi pi-table text-lg text-white"
/>
</Button>
@@ -15,7 +15,7 @@
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
<i
v-tooltip.right="{
value: $t('load3d.backgroundColor'),
value: t('load3d.backgroundColor'),
showDelay: 300
}"
class="pi pi-palette text-lg text-white"
@@ -36,7 +36,7 @@
<Button class="p-button-rounded p-button-text" @click="openImagePicker">
<i
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
value: t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
class="pi pi-image text-lg text-white"
@@ -58,7 +58,7 @@
>
<i
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
value: t('load3d.removeBackgroundImage'),
showDelay: 300
}"
class="pi pi-times text-lg text-white"
@@ -69,29 +69,60 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { ref, watch } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
backgroundColor: string
showGrid: boolean
hasBackgroundImage?: boolean
}>()
const emit = defineEmits<{
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'updateBackgroundImage', file: File | null): void
}>()
const showGrid = defineModel<boolean>('showGrid')
const backgroundColor = defineModel<string>('backgroundColor')
const backgroundImage = defineModel<string>('backgroundImage')
const hasBackgroundImage = computed(
() => backgroundImage.value && backgroundImage.value !== ''
)
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const colorPickerRef = ref<HTMLInputElement | null>(null)
const imagePickerRef = ref<HTMLInputElement | null>(null)
watch(
() => props.backgroundColor,
(newValue) => {
backgroundColor.value = newValue
}
)
watch(
() => props.showGrid,
(newValue) => {
showGrid.value = newValue
}
)
watch(
() => props.hasBackgroundImage,
(newValue) => {
hasBackgroundImage.value = newValue
}
)
const toggleGrid = () => {
showGrid.value = !showGrid.value
emit('toggleGrid', showGrid.value)
}
const updateBackgroundColor = (color: string) => {
backgroundColor.value = color
emit('updateBackgroundColor', color)
}
const openColorPicker = () => {

View File

@@ -15,6 +15,7 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
@@ -23,6 +24,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const vTooltip = Tooltip
const { node } = defineProps<{
node: LGraphNode
}>()

View File

@@ -8,7 +8,7 @@
</Select>
<Button severity="secondary" text rounded @click="exportModel(exportFormat)">
{{ $t('load3d.export') }}
{{ t('load3d.export') }}
</Button>
</template>
@@ -17,6 +17,8 @@ import Button from 'primevue/button'
import Select from 'primevue/select'
import { ref } from 'vue'
import { t } from '@/i18n'
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()

View File

@@ -1,5 +1,5 @@
<template>
<label>{{ $t('load3d.lightIntensity') }}</label>
<label>{{ t('load3d.lightIntensity') }}</label>
<Slider
v-model="lightIntensity"
@@ -13,6 +13,7 @@
<script setup lang="ts">
import Slider from 'primevue/slider'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')

View File

@@ -1,7 +1,7 @@
<template>
<div class="space-y-4">
<div>
<label>{{ $t('load3d.upDirection') }}</label>
<label>{{ t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
:options="upDirectionOptions"
@@ -11,7 +11,7 @@
</div>
<div>
<label>{{ $t('load3d.materialMode') }}</label>
<label>{{ t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
:options="materialModeOptions"

View File

@@ -2,7 +2,7 @@
<div class="space-y-4">
<div v-if="!hasBackgroundImage">
<label>
{{ $t('load3d.backgroundColor') }}
{{ t('load3d.backgroundColor') }}
</label>
<input v-model="backgroundColor" type="color" class="w-full" />
</div>
@@ -10,14 +10,14 @@
<div>
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
<label for="showGrid" class="pl-2">
{{ $t('load3d.showGrid') }}
{{ t('load3d.showGrid') }}
</label>
</div>
<div v-if="!hasBackgroundImage">
<Button
severity="secondary"
:label="$t('load3d.uploadBackgroundImage')"
:label="t('load3d.uploadBackgroundImage')"
icon="pi pi-image"
class="w-full"
@click="openImagePicker"
@@ -34,7 +34,7 @@
<div v-if="hasBackgroundImage" class="space-y-2">
<Button
severity="secondary"
:label="$t('load3d.removeBackgroundImage')"
:label="t('load3d.removeBackgroundImage')"
icon="pi pi-times"
class="w-full"
@click="removeBackgroundImage"
@@ -48,6 +48,8 @@ import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import { ref } from 'vue'
import { t } from '@/i18n'
const backgroundColor = defineModel<string>('backgroundColor')
const showGrid = defineModel<boolean>('showGrid')

View File

@@ -76,7 +76,6 @@ import { useI18n } from 'vue-i18n'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import ComfyLogoTransparent from '@/components/icons/ComfyLogoTransparent.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useCommandStore } from '@/stores/commandStore'
@@ -161,7 +160,7 @@ const extraMenuItems = computed(() => [
key: 'browse-templates',
label: t('menuLabels.Browse Templates'),
icon: 'icon-[comfy--template]',
command: () => useWorkflowTemplateSelectorDialog().show('menu')
command: () => commandStore.execute('Comfy.BrowseTemplates')
},
{
key: 'settings',

View File

@@ -12,18 +12,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
)
const openTemplates = () => {
useWorkflowTemplateSelectorDialog().show('sidebar')
void commandStore.execute('Comfy.BrowseTemplates')
}
</script>

View File

@@ -79,11 +79,9 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
// Mock the useSubscription composable
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: { value: true },
fetchStatus: mockFetchStatus
isActiveSubscription: vi.fn().mockReturnValue(true)
}))
}))
@@ -107,15 +105,6 @@ vi.mock('@/components/common/UserCredit.vue', () => ({
}
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
name: 'SubscribeButtonMock',
render() {
return h('div', 'Subscribe Button')
}
}
}))
describe('CurrentUserPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -148,9 +137,9 @@ describe('CurrentUserPopover', () => {
it('renders logout button with correct props', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
const logoutButton = buttons[1]
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
@@ -160,9 +149,9 @@ describe('CurrentUserPopover', () => {
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the settings button (third button)
// Find all buttons and get the settings button (first one)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[2]
const settingsButton = buttons[0]
// Click the settings button
await settingsButton.trigger('click')
@@ -178,9 +167,9 @@ describe('CurrentUserPopover', () => {
it('calls logout function and emits close event when logout button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
const logoutButton = buttons[1]
// Click the logout button
await logoutButton.trigger('click')
@@ -196,16 +185,16 @@ describe('CurrentUserPopover', () => {
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the Partner Nodes info button (first one)
// Find all buttons and get the API pricing button (third one now)
const buttons = wrapper.findAllComponents(Button)
const partnerNodesButton = buttons[0]
const apiPricingButton = buttons[2]
// Click the Partner Nodes button
await partnerNodesButton.trigger('click')
// Click the API pricing button
await apiPricingButton.trigger('click')
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
'https://docs.comfy.org/tutorials/api-nodes/pricing',
'_blank'
)
@@ -217,9 +206,9 @@ describe('CurrentUserPopover', () => {
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the top-up button (second one)
// Find all buttons and get the top-up button (last one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[1]
const topUpButton = buttons[buttons.length - 1]
// Click the top-up button
await topUpButton.trigger('click')

View File

@@ -23,38 +23,6 @@
</div>
</div>
<div v-if="isActiveSubscription" class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<Button
:label="$t('subscription.partnerNodesCredits')"
severity="secondary"
text
size="small"
class="pl-6 p-0 h-auto justify-start"
:pt="{
root: {
class: 'hover:bg-transparent active:bg-transparent'
}
}"
@click="handleOpenPartnerNodesInfo"
/>
</div>
<Button
:label="$t('credits.topUp.topUp')"
severity="secondary"
size="small"
@click="handleTopUp"
/>
</div>
<SubscribeButton
v-else
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
<Divider class="my-2" />
<Button
@@ -67,17 +35,6 @@
@click="handleOpenUserSettings"
/>
<Button
v-if="isActiveSubscription"
class="justify-start"
:label="$t(planSettingsLabel)"
icon="pi pi-receipt"
text
fluid
severity="secondary"
@click="handleOpenPlanAndCreditsSettings"
/>
<Divider class="my-2" />
<Button
@@ -89,6 +46,34 @@
severity="secondary"
@click="handleLogout"
/>
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('credits.apiPricing')"
icon="pi pi-external-link"
text
fluid
severity="secondary"
@click="handleOpenApiPricing"
/>
<Divider class="my-2" />
<div class="flex w-full flex-col gap-2 p-2">
<div class="text-sm text-muted">
{{ $t('credits.yourCreditBalance') }}
</div>
<div class="flex items-center justify-between">
<UserCredit text-class="text-2xl" />
<Button
v-if="isActiveSubscription"
:label="$t('credits.topUp.topUp')"
@click="handleTopUp"
/>
</div>
</div>
</div>
</template>
@@ -101,60 +86,37 @@ import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
const emit = defineEmits<{
close: []
}>()
const planSettingsLabel = isCloud
? 'settingsCategories.PlanCredits'
: 'settingsCategories.Credits'
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { isActiveSubscription } = useSubscription()
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
emit('close')
}
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('subscription')
} else {
dialogService.showSettingsDialog('credits')
}
emit('close')
}
const handleTopUp = () => {
dialogService.showTopUpCreditsDialog()
emit('close')
}
const handleOpenPartnerNodesInfo = () => {
window.open(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
'_blank'
)
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')
}
const handleSubscribed = async () => {
await fetchStatus()
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
emit('close')
}
onMounted(() => {

View File

@@ -7,8 +7,6 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -84,9 +82,6 @@ export const useFirebaseAuthActions = () => {
)
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
const { isActiveSubscription } = useSubscription()
if (!isActiveSubscription.value) return
const response = await authStore.initiateCreditPurchase({
amount_micros: usdToMicros(amount),
currency: 'usd'
@@ -100,7 +95,7 @@ export const useFirebaseAuthActions = () => {
)
}
useTelemetry()?.startTopupTracking()
// Go to Stripe checkout page
window.open(response.checkout_url, '_blank')
}, reportError)
@@ -117,9 +112,7 @@ export const useFirebaseAuthActions = () => {
}, reportError)
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
const result = await authStore.fetchBalance()
// Top-up completion tracking happens in UsageLogsTable when events are fetched
return result
return await authStore.fetchBalance()
}, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {

View File

@@ -4,13 +4,15 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -19,7 +21,9 @@ function useVueNodeLifecycleIndividual() {
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync, stopSync } = useLayoutSync()
const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
@@ -30,22 +34,13 @@ function useVueNodeLifecycleIndividual() {
const manager = useGraphNodeManager(activeGraph)
nodeManager.value = manager
// Only initialize layout store if it's empty (first time enabling Vue nodes)
// On subsequent mode switches, preserve existing layout data to prevent drift
const hasExistingLayouts = activeGraph._nodes.some(
(node: LGraphNode) =>
layoutStore.getNodeLayoutRef(node.id.toString()).value !== null
)
if (!hasExistingLayouts) {
// First time: initialize from Litegraph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
}))
layoutStore.initializeFromLiteGraph(nodes)
}
// Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
}))
layoutStore.initializeFromLiteGraph(nodes)
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of activeGraph.reroutes.values()) {
@@ -79,17 +74,23 @@ function useVueNodeLifecycleIndividual() {
/* empty */
}
nodeManager.value = null
// Stop layout sync when Vue nodes are disabled
stopSync()
}
// Watch for Vue nodes enabled state changes
watch(
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),
(enabled) => {
(enabled, wasEnabled) => {
if (enabled) {
initializeNodeManager()
ensureCorrectLayoutScale()
if (!wasEnabled && !isVueNodeToastDismissed.value) {
useToastStore().add({
group: 'vue-nodes-migration',
severity: 'info',
life: 0
})
}
} else {
comfyApp.canvas?.setDirty(true, true)
disposeNodeManagerAndSyncs()

View File

@@ -19,6 +19,7 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { SUPPORT_URL } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
@@ -73,7 +74,6 @@ export function useCoreCommands(): ComfyCommand[] {
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const telemetry = useTelemetry()
const bottomPanelStore = useBottomPanelStore()
@@ -102,14 +102,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'New Blank Workflow',
menubarLabel: 'New',
category: 'essentials' as const,
function: async () => {
const previousWorkflowHadNodes = app.graph._nodes.length > 0
await workflowService.loadBlankWorkflow()
telemetry?.trackWorkflowCreated({
workflow_type: 'blank',
previous_workflow_had_nodes: previousWorkflowHadNodes
})
}
function: () => workflowService.loadBlankWorkflow()
},
{
id: 'Comfy.OpenWorkflow',
@@ -125,14 +118,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.LoadDefaultWorkflow',
icon: 'pi pi-code',
label: 'Load Default Workflow',
function: async () => {
const previousWorkflowHadNodes = app.graph._nodes.length > 0
await workflowService.loadDefaultWorkflow()
telemetry?.trackWorkflowCreated({
workflow_type: 'default',
previous_workflow_had_nodes: previousWorkflowHadNodes
})
}
function: () => workflowService.loadDefaultWorkflow()
},
{
id: 'Comfy.SaveWorkflow',
@@ -474,7 +460,9 @@ export function useCoreCommands(): ComfyCommand[] {
const batchCount = useQueueSettingsStore().batchCount
useTelemetry()?.trackWorkflowExecution()
if (isCloud) {
useTelemetry()?.trackWorkflowExecution()
}
await app.queuePrompt(0, batchCount)
}
@@ -493,7 +481,9 @@ export function useCoreCommands(): ComfyCommand[] {
const batchCount = useQueueSettingsStore().batchCount
useTelemetry()?.trackWorkflowExecution()
if (isCloud) {
useTelemetry()?.trackWorkflowExecution()
}
await app.queuePrompt(-1, batchCount)
}
@@ -731,11 +721,6 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Issues',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'github',
is_external: true,
source: 'menu'
})
window.open(
'https://github.com/comfyanonymous/ComfyUI/issues',
'_blank'
@@ -749,11 +734,6 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Docs',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'docs',
is_external: true,
source: 'menu'
})
window.open('https://docs.comfy.org/', '_blank')
}
},
@@ -764,11 +744,6 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'Comfy-Org Discord',
versionAdded: '1.5.5',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'discord',
is_external: true,
source: 'menu'
})
window.open('https://www.comfy.org/discord', '_blank')
}
},
@@ -826,11 +801,6 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Forum',
versionAdded: '1.8.2',
function: () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'menu'
})
window.open('https://forum.comfy.org/', '_blank')
}
},

View File

@@ -1,536 +0,0 @@
import { toRef } from '@vueuse/core'
import type { MaybeRef } from '@vueuse/core'
import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
CameraConfig,
CameraType,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useLoad3dService } from '@/services/load3dService'
type Load3dReadyCallback = (load3d: Load3d) => void
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const nodeRef = toRef(nodeOrRef)
let load3d: Load3d | null = null
const sceneConfig = ref<SceneConfig>({
showGrid: true,
backgroundColor: '#000000',
backgroundImage: ''
})
const modelConfig = ref<ModelConfig>({
upDirection: 'original',
materialMode: 'original'
})
const cameraConfig = ref<CameraConfig>({
cameraType: 'perspective',
fov: 75
})
const lightConfig = ref<LightConfig>({
intensity: 5
})
const isRecording = ref(false)
const hasRecording = ref(false)
const recordingDuration = ref(0)
const animations = ref<AnimationItem[]>([])
const playing = ref(false)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const loading = ref(false)
const loadingMessage = ref('')
const isPreview = ref(false)
const initializeLoad3d = async (containerRef: HTMLElement) => {
const rawNode = toRaw(nodeRef.value)
if (!containerRef || !rawNode) return
const node = rawNode as LGraphNode
try {
load3d = new Load3d(containerRef, {
node
})
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (!(widthWidget && heightWidget)) {
isPreview.value = true
}
await restoreConfigurationsFromNode(node)
node.onMouseEnter = function () {
load3d?.refreshViewport()
load3d?.updateStatusMouseOnNode(true)
}
node.onMouseLeave = function () {
load3d?.updateStatusMouseOnNode(false)
}
node.onResize = function () {
load3d?.handleResize()
}
node.onDrawBackground = function () {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
}
}
node.onRemoved = function () {
useLoad3dService().removeLoad3d(node)
pendingCallbacks.delete(node)
}
nodeToLoad3dMap.set(node, load3d)
const callbacks = pendingCallbacks.get(node)
if (callbacks && load3d) {
callbacks.forEach((callback) => {
if (load3d) {
callback(load3d)
}
})
pendingCallbacks.delete(node)
}
handleEvents('add')
} catch (error) {
console.error('Error initializing Load3d:', error)
useToastStore().addAlert(t('toastMessages.failedToInitializeLoad3d'))
}
}
const restoreConfigurationsFromNode = async (node: LGraphNode) => {
if (!load3d) return
// Restore configs - watchers will handle applying them to the Three.js scene
const savedSceneConfig = node.properties['Scene Config'] as SceneConfig
if (savedSceneConfig) {
sceneConfig.value = savedSceneConfig
}
const savedModelConfig = node.properties['Model Config'] as ModelConfig
if (savedModelConfig) {
modelConfig.value = savedModelConfig
}
const savedCameraConfig = node.properties['Camera Config'] as CameraConfig
const cameraStateToRestore = savedCameraConfig?.state
if (savedCameraConfig) {
cameraConfig.value = savedCameraConfig
}
const savedLightConfig = node.properties['Light Config'] as LightConfig
if (savedLightConfig) {
lightConfig.value = savedLightConfig
}
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget?.value) {
const modelUrl = getModelUrl(modelWidget.value as string)
if (modelUrl) {
loading.value = true
loadingMessage.value = t('load3d.reloadingModel')
try {
await load3d.loadModel(modelUrl)
if (cameraStateToRestore) {
await nextTick()
load3d.setCameraState(cameraStateToRestore)
}
} catch (error) {
console.error('Failed to reload model:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
} finally {
loading.value = false
loadingMessage.value = ''
}
}
} else if (cameraStateToRestore) {
load3d.setCameraState(cameraStateToRestore)
}
}
const getModelUrl = (modelPath: string): string | null => {
if (!modelPath) return null
try {
if (modelPath.startsWith('http')) {
return modelPath
}
const [subfolder, filename] = Load3dUtils.splitFilePath(modelPath)
return api.apiURL(
Load3dUtils.getResourceURL(
subfolder,
filename,
isPreview.value ? 'output' : 'input'
)
)
} catch (error) {
console.error('Failed to construct model URL:', error)
return null
}
}
const waitForLoad3d = (callback: Load3dReadyCallback) => {
const rawNode = toRaw(nodeRef.value)
if (!rawNode) return
const node = rawNode as LGraphNode
const existingInstance = nodeToLoad3dMap.get(node)
if (existingInstance) {
callback(existingInstance)
return
}
if (!pendingCallbacks.has(node)) {
pendingCallbacks.set(node, [])
}
pendingCallbacks.get(node)!.push(callback)
}
watch(
sceneConfig,
(newValue) => {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Scene Config'] = newValue
load3d.toggleGrid(newValue.showGrid)
load3d.setBackgroundColor(newValue.backgroundColor)
void load3d.setBackgroundImage(newValue.backgroundImage || '')
}
},
{ deep: true }
)
watch(
modelConfig,
(newValue) => {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Model Config'] = newValue
load3d.setUpDirection(newValue.upDirection)
load3d.setMaterialMode(newValue.materialMode)
}
},
{ deep: true }
)
watch(
cameraConfig,
(newValue) => {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
}
},
{ deep: true }
)
watch(
lightConfig,
(newValue) => {
if (load3d && nodeRef.value) {
nodeRef.value.properties['Light Config'] = newValue
load3d.setLightIntensity(newValue.intensity)
}
},
{ deep: true }
)
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 handleMouseEnter = () => {
load3d?.updateStatusMouseOnScene(true)
}
const handleMouseLeave = () => {
load3d?.updateStatusMouseOnScene(false)
}
const handleStartRecording = async () => {
if (load3d) {
await load3d.startRecording()
isRecording.value = true
}
}
const handleStopRecording = () => {
if (load3d) {
load3d.stopRecording()
isRecording.value = false
recordingDuration.value = load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
const handleExportRecording = () => {
if (load3d) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${timestamp}-scene-recording.mp4`
load3d.exportRecording(filename)
}
}
const handleClearRecording = () => {
if (load3d) {
load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
}
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
sceneConfig.value.backgroundImage = ''
await load3d?.setBackgroundImage('')
return
}
const resourceFolder =
(nodeRef.value?.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
sceneConfig.value.backgroundImage = uploadedPath
await load3d?.setBackgroundImage(uploadedPath)
}
const handleExportModel = async (format: string) => {
if (!load3d) {
useToastStore().addAlert(t('toastMessages.no3dSceneToExport'))
return
}
try {
await load3d.exportModel(format)
} catch (error) {
console.error('Error exporting model:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', {
format: format.toUpperCase()
})
)
}
}
const handleModelDrop = async (file: File) => {
if (!load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
const node = toRaw(nodeRef.value)
if (!node) return
try {
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
loading.value = true
loadingMessage.value = t('load3d.uploadingModel')
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) {
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
return
}
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadedPath),
isPreview.value ? 'output' : 'input'
)
)
loadingMessage.value = t('load3d.loadingModel')
await load3d.loadModel(modelUrl)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
const options = modelWidget.options as { values?: string[] } | undefined
if (options?.values && !options.values.includes(uploadedPath)) {
options.values.push(uploadedPath)
}
modelWidget.value = uploadedPath
}
} catch (error) {
console.error('Model drop failed:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
} finally {
loading.value = false
loadingMessage.value = ''
}
}
const eventConfig = {
materialModeChange: (value: string) => {
modelConfig.value.materialMode = value as MaterialMode
},
backgroundColorChange: (value: string) => {
sceneConfig.value.backgroundColor = value
},
lightIntensityChange: (value: number) => {
lightConfig.value.intensity = value
},
fovChange: (value: number) => {
cameraConfig.value.fov = value
},
cameraTypeChange: (value: string) => {
cameraConfig.value.cameraType = value as CameraType
},
showGridChange: (value: boolean) => {
sceneConfig.value.showGrid = value
},
upDirectionChange: (value: string) => {
modelConfig.value.upDirection = value as UpDirection
},
backgroundImageChange: (value: string) => {
sceneConfig.value.backgroundImage = value
},
backgroundImageLoadingStart: () => {
loadingMessage.value = t('load3d.loadingBackgroundImage')
loading.value = true
},
backgroundImageLoadingEnd: () => {
loadingMessage.value = ''
loading.value = false
},
modelLoadingStart: () => {
loadingMessage.value = t('load3d.loadingModel')
loading.value = true
},
modelLoadingEnd: () => {
loadingMessage.value = ''
loading.value = false
},
exportLoadingStart: (message: string) => {
loadingMessage.value = message || t('load3d.exportingModel')
loading.value = true
},
exportLoadingEnd: () => {
loadingMessage.value = ''
loading.value = false
},
recordingStatusChange: (value: boolean) => {
isRecording.value = value
if (!value && load3d) {
recordingDuration.value = load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
},
animationListChange: (newValue: any) => {
animations.value = newValue
}
} as const
const handleEvents = (action: 'add' | 'remove') => {
Object.entries(eventConfig).forEach(([event, handler]) => {
const method = `${action}EventListener` as const
load3d?.[method](event, handler)
})
}
const cleanup = () => {
handleEvents('remove')
const rawNode = toRaw(nodeRef.value)
if (!rawNode) return
const node = rawNode as LGraphNode
if (nodeToLoad3dMap.get(node) === load3d) {
nodeToLoad3dMap.delete(node)
}
load3d?.remove()
load3d = null
}
return {
// state
load3d,
sceneConfig,
modelConfig,
cameraConfig,
lightConfig,
isRecording,
isPreview,
hasRecording,
recordingDuration,
animations,
playing,
selectedSpeed,
selectedAnimation,
loading,
loadingMessage,
// Methods
initializeLoad3d,
waitForLoad3d,
handleMouseEnter,
handleMouseLeave,
handleStartRecording,
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,
cleanup
}
}

View File

@@ -1,70 +0,0 @@
import { computed, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
interface UseLoad3dDragOptions {
onModelDrop: (file: File) => void | Promise<void>
disabled?: MaybeRefOrGetter<boolean>
}
export function useLoad3dDrag(options: UseLoad3dDragOptions) {
const isDragging = ref(false)
const dragMessage = ref('')
const isDisabled = computed(() => toValue(options.disabled) ?? false)
function isValidModelFile(file: File): boolean {
const fileName = file.name.toLowerCase()
const extension = fileName.substring(fileName.lastIndexOf('.'))
return SUPPORTED_EXTENSIONS.has(extension)
}
function handleDragOver(event: DragEvent) {
if (isDisabled.value) return
if (!event.dataTransfer) return
const hasFiles = event.dataTransfer.types.includes('Files')
if (!hasFiles) return
isDragging.value = true
event.dataTransfer.dropEffect = 'copy'
dragMessage.value = t('load3d.dropToLoad')
}
function handleDragLeave() {
isDragging.value = false
}
async function handleDrop(event: DragEvent) {
isDragging.value = false
if (isDisabled.value) return
if (!event.dataTransfer) return
const files = Array.from(event.dataTransfer.files)
if (files.length === 0) return
const modelFile = files.find(isValidModelFile)
if (modelFile) {
await options.onModelDrop(modelFile)
} else {
useToastStore().addAlert(t('load3d.unsupportedFileType'))
}
}
return {
isDragging,
dragMessage,
handleDragOver,
handleDragLeave,
handleDrop
}
}

View File

@@ -10,7 +10,6 @@ import type {
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useLoad3dService } from '@/services/load3dService'
interface Load3dViewerState {
@@ -23,6 +22,7 @@ interface Load3dViewerState {
backgroundImage: string
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold: number
}
export const useLoad3dViewer = (node: LGraphNode) => {
@@ -35,8 +35,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
const hasBackgroundImage = ref(false)
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const needApplyChanges = ref(true)
const isPreview = ref(false)
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
@@ -50,7 +50,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
cameraState: null,
backgroundImage: '',
upDirection: 'original',
materialMode: 'original'
materialMode: 'original',
edgeThreshold: 85
})
watch(backgroundColor, (newColor) => {
@@ -148,6 +149,18 @@ export const useLoad3dViewer = (node: LGraphNode) => {
}
})
watch(edgeThreshold, (newValue) => {
if (!load3d) return
try {
load3d.setEdgeThreshold(Number(newValue))
} catch (error) {
console.error('Error updating edge threshold:', error)
useToastStore().addAlert(
t('toastMessages.failedToUpdateEdgeThreshold', { threshold: newValue })
)
}
})
const initializeViewer = async (
containerRef: HTMLElement,
source: Load3d
@@ -165,52 +178,34 @@ export const useLoad3dViewer = (node: LGraphNode) => {
await useLoad3dService().copyLoad3dState(source, load3d)
const sourceCameraType = source.getCurrentCameraType()
const sourceCameraState = source.getCameraState()
const sceneConfig = node.properties['Scene Config'] as any
const modelConfig = node.properties['Model Config'] as any
const cameraConfig = node.properties['Camera Config'] as any
const lightConfig = node.properties['Light Config'] as any
cameraType.value = sourceCameraType
backgroundColor.value = source.sceneManager.currentBackgroundColor
showGrid.value = source.sceneManager.gridHelper.visible
lightIntensity.value = (node.properties['Light Intensity'] as number) || 1
isPreview.value = node.type === 'Preview3D'
if (sceneConfig) {
backgroundColor.value =
sceneConfig.backgroundColor ||
source.sceneManager.currentBackgroundColor
showGrid.value =
sceneConfig.showGrid ?? source.sceneManager.gridHelper.visible
const backgroundInfo = source.sceneManager.getCurrentBackgroundInfo()
if (backgroundInfo.type === 'image' && sceneConfig.backgroundImage) {
backgroundImage.value = sceneConfig.backgroundImage
hasBackgroundImage.value = true
} else {
backgroundImage.value = ''
hasBackgroundImage.value = false
}
}
if (cameraConfig) {
cameraType.value =
cameraConfig.cameraType || source.getCurrentCameraType()
fov.value =
cameraConfig.fov || source.cameraManager.perspectiveCamera.fov
}
if (lightConfig) {
lightIntensity.value = lightConfig.intensity || 1
const backgroundInfo = source.sceneManager.getCurrentBackgroundInfo()
if (
backgroundInfo.type === 'image' &&
node.properties['Background Image']
) {
backgroundImage.value = node.properties['Background Image'] as string
hasBackgroundImage.value = true
} else {
lightIntensity.value = 1
backgroundImage.value = ''
hasBackgroundImage.value = false
}
if (modelConfig) {
upDirection.value =
modelConfig.upDirection || source.modelManager.currentUpDirection
materialMode.value =
modelConfig.materialMode || source.modelManager.materialMode
if (sourceCameraType === 'perspective') {
fov.value = source.cameraManager.perspectiveCamera.fov
}
upDirection.value = source.modelManager.currentUpDirection
materialMode.value = source.modelManager.materialMode
edgeThreshold.value = (node.properties['Edge Threshold'] as number) || 85
initialState.value = {
backgroundColor: backgroundColor.value,
showGrid: showGrid.value,
@@ -220,7 +215,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
cameraState: sourceCameraState,
backgroundImage: backgroundImage.value,
upDirection: upDirection.value,
materialMode: materialMode.value
materialMode: materialMode.value,
edgeThreshold: edgeThreshold.value
}
const width = node.widgets?.find((w) => w.name === 'width')
@@ -271,31 +267,16 @@ export const useLoad3dViewer = (node: LGraphNode) => {
needApplyChanges.value = false
if (nodeValue.properties) {
nodeValue.properties['Scene Config'] = {
showGrid: initialState.value.showGrid,
backgroundColor: initialState.value.backgroundColor,
backgroundImage: initialState.value.backgroundImage
}
nodeValue.properties['Camera Config'] = {
cameraType: initialState.value.cameraType,
fov: initialState.value.fov
}
nodeValue.properties['Light Config'] = {
intensity: initialState.value.lightIntensity
}
nodeValue.properties['Model Config'] = {
upDirection: initialState.value.upDirection,
materialMode: initialState.value.materialMode
}
const currentCameraConfig = nodeValue.properties['Camera Config'] as any
nodeValue.properties['Camera Config'] = {
...currentCameraConfig,
state: initialState.value.cameraState
}
nodeValue.properties['Background Color'] =
initialState.value.backgroundColor
nodeValue.properties['Show Grid'] = initialState.value.showGrid
nodeValue.properties['Camera Type'] = initialState.value.cameraType
nodeValue.properties['FOV'] = initialState.value.fov
nodeValue.properties['Light Intensity'] =
initialState.value.lightIntensity
nodeValue.properties['Camera Info'] = initialState.value.cameraState
nodeValue.properties['Background Image'] =
initialState.value.backgroundImage
}
}
@@ -306,31 +287,20 @@ export const useLoad3dViewer = (node: LGraphNode) => {
const nodeValue = node
if (nodeValue.properties) {
nodeValue.properties['Scene Config'] = {
showGrid: showGrid.value,
backgroundColor: backgroundColor.value,
backgroundImage: backgroundImage.value
}
nodeValue.properties['Camera Config'] = {
cameraType: cameraType.value,
fov: fov.value,
state: viewerCameraState
}
nodeValue.properties['Light Config'] = {
intensity: lightIntensity.value
}
nodeValue.properties['Model Config'] = {
upDirection: upDirection.value,
materialMode: materialMode.value
}
nodeValue.properties['Background Color'] = backgroundColor.value
nodeValue.properties['Show Grid'] = showGrid.value
nodeValue.properties['Camera Type'] = cameraType.value
nodeValue.properties['FOV'] = fov.value
nodeValue.properties['Light Intensity'] = lightIntensity.value
nodeValue.properties['Camera Info'] = viewerCameraState
nodeValue.properties['Background Image'] = backgroundImage.value
}
await useLoad3dService().copyLoad3dState(load3d, sourceLoad3d)
await sourceLoad3d.setBackgroundImage(backgroundImage.value)
if (backgroundImage.value) {
await sourceLoad3d.setBackgroundImage(backgroundImage.value)
}
sourceLoad3d.forceRender()
@@ -371,49 +341,6 @@ export const useLoad3dViewer = (node: LGraphNode) => {
}
}
const handleModelDrop = async (file: File) => {
if (!load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
try {
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) {
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
return
}
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadedPath),
'input'
)
)
await load3d.loadModel(modelUrl)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
const options = modelWidget.options as { values?: string[] } | undefined
if (options?.values && !options.values.includes(uploadedPath)) {
options.values.push(uploadedPath)
}
modelWidget.value = uploadedPath
}
} catch (error) {
console.error('Model drop failed:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
}
}
const cleanup = () => {
load3d?.remove()
load3d = null
@@ -431,8 +358,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
hasBackgroundImage,
upDirection,
materialMode,
edgeThreshold,
needApplyChanges,
isPreview,
// Methods
initializeViewer,
@@ -444,7 +371,6 @@ export const useLoad3dViewer = (node: LGraphNode) => {
applyChanges,
refreshViewport,
handleBackgroundImageUpdate,
handleModelDrop,
cleanup
}
}

View File

@@ -13,8 +13,8 @@ export const useWorkflowTemplateSelectorDialog = () => {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(source: 'sidebar' | 'menu' | 'command' = 'command') {
useTelemetry()?.trackTemplateLibraryOpened({ source })
function show() {
useTelemetry()?.trackTemplateLibraryOpened({ source: 'command' })
dialogService.showLayoutDialog({
key: DIALOG_KEY,

View File

@@ -1,10 +1,11 @@
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -20,18 +21,6 @@ import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
import { isLoad3dNode } from '@/utils/litegraphUtil'
const inputSpecLoad3D: CustomInputSpec = {
name: 'image',
type: 'Load3D',
isPreview: false
}
const inputSpecPreview3D: CustomInputSpec = {
name: 'image',
type: 'Preview3D',
isPreview: true
}
async function handleModelUpload(files: FileList, node: any) {
if (!files?.length) return
@@ -60,13 +49,7 @@ async function handleModelUpload(files: FileList, node: any) {
)
)
useLoad3d(node).waitForLoad3d((load3d) => {
try {
load3d.loadModel(modelUrl)
} catch (error) {
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
}
})
await useLoad3dService().getLoad3d(node)?.loadModel(modelUrl)
if (uploadPath && modelWidget) {
if (!modelWidget.options?.values?.includes(uploadPath)) {
@@ -123,6 +106,16 @@ useExtensionService().registerExtension({
defaultValue: true,
experimental: true
},
{
id: 'Comfy.Load3D.ShowPreview',
category: ['3D', 'Scene', 'Initial Preview Visibility'],
name: 'Initial Preview Visibility',
tooltip:
'Controls whether the preview screen is visible by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation.',
type: 'boolean',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.Load3D.BackgroundColor',
category: ['3D', 'Scene', 'Initial Background Color'],
@@ -267,9 +260,7 @@ useExtensionService().registerExtension({
)
node.addWidget('button', 'clear', 'clear', () => {
useLoad3d(node).waitForLoad3d((load3d) => {
load3d.clearModel()
})
useLoad3dService().getLoad3d(node)?.clearModel()
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
@@ -277,16 +268,21 @@ useExtensionService().registerExtension({
}
})
const widget = new ComponentWidgetImpl({
node: node,
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Load3D',
isAnimation: false,
isPreview: false
}
const widget = new ComponentWidgetImpl({
node,
name: inputSpec.name,
component: Load3D,
inputSpec: inputSpecLoad3D,
inputSpec,
options: {}
})
widget.type = 'load3D'
addWidget(node, widget)
return { widget }
@@ -313,9 +309,8 @@ useExtensionService().registerExtension({
await nextTick()
useLoad3d(node).waitForLoad3d((load3d) => {
const cameraConfig = node.properties['Camera Config'] as any
const cameraState = cameraConfig?.state
useLoad3dService().waitForLoad3d(node, (load3d) => {
let cameraState = node.properties['Camera Info']
const config = new Load3DConfiguration(load3d)
@@ -325,36 +320,159 @@ useExtensionService().registerExtension({
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
if (modelWidget && width && height && sceneWidget) {
const settings = {
loadFolder: 'input',
modelWidget: modelWidget,
cameraState: cameraState,
width: width,
height: height
}
config.configure(settings)
config.configure('input', modelWidget, cameraState, width, height)
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
node.properties['Camera Info'] = load3d.getCameraState()
load3d.stopRecording()
const {
scene: imageData,
mask: maskData,
normal: normalData,
lineart: lineartData
} = await load3d.captureScene(
width.value as number,
height.value as number
)
const [data, dataMask, dataNormal, dataLineart] = await Promise.all([
Load3dUtils.uploadTempImage(imageData, 'scene'),
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
Load3dUtils.uploadTempImage(normalData, 'scene_normal'),
Load3dUtils.uploadTempImage(lineartData, 'scene_lineart')
])
load3d.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
lineart: `threed/${dataLineart.name} [temp]`,
camera_info: node.properties['Camera Info'],
recording: ''
}
const cameraConfig = (node.properties['Camera Config'] as any) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
const recordingData = load3d.getRecordingData()
currentLoad3d.stopRecording()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
}
return returnVal
}
}
})
}
})
useExtensionService().registerExtension({
name: 'Comfy.Load3DAnimation',
getCustomWidgets() {
return {
LOAD_3D_ANIMATION(node) {
const fileInput = createFileInput('.gltf,.glb,.fbx', false)
node.properties['Resource Folder'] = ''
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
useLoad3dService().getLoad3d(node)?.clearModel()
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = ''
}
})
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Load3DAnimation',
isAnimation: true,
isPreview: false
}
const widget = new ComponentWidgetImpl({
node,
name: inputSpec.name,
component: Load3DAnimation,
inputSpec,
options: {}
})
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'Load3DAnimation') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 700)])
await nextTick()
useLoad3dService().waitForLoad3d(node, (load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
let cameraState = node.properties['Camera Info']
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (modelWidget && width && height && sceneWidget && load3d) {
const config = new Load3DConfiguration(load3d)
config.configure('input', modelWidget, cameraState, width, height)
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
const load3dAnimation = load3d as Load3dAnimation
load3dAnimation.toggleAnimation(false)
if (load3dAnimation.isRecording()) {
load3dAnimation.stopRecording()
}
const {
scene: imageData,
mask: maskData,
normal: normalData
} = await currentLoad3d.captureScene(
} = await load3dAnimation.captureScene(
width.value as number,
height.value as number
)
@@ -365,19 +483,17 @@ useExtensionService().registerExtension({
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
])
currentLoad3d.handleResize()
load3dAnimation.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info:
(node.properties['Camera Config'] as any)?.state || null,
camera_info: node.properties['Camera Info'],
recording: ''
}
const recordingData = currentLoad3d.getRecordingData()
const recordingData = load3dAnimation.getRecordingData()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
@@ -415,16 +531,21 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
PREVIEW_3D(node) {
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3D',
isAnimation: false,
isPreview: true
}
const widget = new ComponentWidgetImpl({
node,
name: inputSpecPreview3D.name,
name: inputSpec.name,
component: Load3D,
inputSpec: inputSpecPreview3D,
inputSpec,
options: {}
})
widget.type = 'load3D'
addWidget(node, widget)
return { widget }
@@ -443,7 +564,7 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
useLoad3d(node).waitForLoad3d((load3d) => {
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
@@ -454,16 +575,9 @@ useExtensionService().registerExtension({
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const cameraConfig = node.properties['Camera Config'] as any
const cameraState = cameraConfig?.state
const cameraState = node.properties['Camera Info']
const settings = {
loadFolder: 'output',
modelWidget: modelWidget,
cameraState: cameraState
}
config.configure(settings)
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
@@ -478,24 +592,98 @@ useExtensionService().registerExtension({
}
let cameraState = message.result[1]
let bgImagePath = message.result[2]
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
const settings = {
loadFolder: 'output',
modelWidget: modelWidget,
cameraState: cameraState,
bgImagePath: bgImagePath
}
config.configure(settings)
if (bgImagePath) {
load3d.setBackgroundImage(bgImagePath)
}
config.configure('output', modelWidget, cameraState)
}
}
})
}
})
useExtensionService().registerExtension({
name: 'Comfy.Preview3DAnimation',
async beforeRegisterNodeDef(_nodeType, nodeData) {
if ('Preview3DAnimation' === nodeData.name) {
// @ts-expect-error InputSpec is not typed correctly
nodeData.input.required.image = ['PREVIEW_3D_ANIMATION']
}
},
getCustomWidgets() {
return {
PREVIEW_3D_ANIMATION(node) {
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3DAnimation',
isAnimation: true,
isPreview: true
}
const widget = new ComponentWidgetImpl({
node,
name: inputSpec.name,
component: Load3DAnimation,
inputSpec,
options: {}
})
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'Preview3DAnimation') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 550)])
await nextTick()
const onExecuted = node.onExecuted
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
config.configure('output', modelWidget, cameraState)
}
}
})

View File

@@ -15,9 +15,14 @@ export class AnimationManager implements AnimationManagerInterface {
animationSpeed: number = 1.0
private eventManager: EventManagerInterface
private getCurrentModel: () => THREE.Object3D | null
constructor(eventManager: EventManagerInterface) {
constructor(
eventManager: EventManagerInterface,
getCurrentModel: () => THREE.Object3D | null
) {
this.eventManager = eventManager
this.getCurrentModel = getCurrentModel
}
init(): void {}
@@ -47,24 +52,23 @@ export class AnimationManager implements AnimationManagerInterface {
let animations: THREE.AnimationClip[] = []
if (model.animations?.length > 0) {
animations = model.animations
} else if (
originalModel &&
'animations' in originalModel &&
Array.isArray(originalModel.animations)
) {
} else if (originalModel && 'animations' in originalModel) {
animations = originalModel.animations
}
if (animations.length > 0) {
this.animationClips = animations
this.currentAnimation = new THREE.AnimationMixer(model)
if (model.type === 'Scene') {
this.currentAnimation = new THREE.AnimationMixer(model)
} else {
this.currentAnimation = new THREE.AnimationMixer(
this.getCurrentModel()!
)
}
if (this.animationClips.length > 0) {
this.updateSelectedAnimation(0)
}
} else {
this.animationClips = []
}
this.updateAnimationList()

View File

@@ -82,17 +82,7 @@ export class CameraManager implements CameraManagerInterface {
if (this.controls) {
this.controls.addEventListener('end', () => {
const cameraState = this.getCameraState()
const cameraConfig = this.nodeStorage.loadNodeProperty(
'Camera Config',
{
cameraType: this.getCurrentCameraType(),
fov: this.perspectiveCamera.fov
}
)
cameraConfig.state = cameraState
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
this.nodeStorage.storeNodeProperty('Camera Info', this.getCameraState())
})
}
}

View File

@@ -24,14 +24,13 @@ export class ControlsManager implements ControlsManagerInterface {
this.nodeStorage = nodeStorage
this.camera = camera
const container = renderer.domElement.parentElement || renderer.domElement
this.controls = new OrbitControls(camera, container)
this.controls = new OrbitControls(camera, renderer.domElement)
this.controls.enableDamping = true
}
init(): void {
this.controls.addEventListener('end', () => {
const cameraState = {
this.nodeStorage.storeNodeProperty('Camera Info', {
position: this.camera.position.clone(),
target: this.controls.target.clone(),
zoom:
@@ -42,17 +41,7 @@ export class ControlsManager implements ControlsManagerInterface {
this.camera instanceof THREE.PerspectiveCamera
? 'perspective'
: 'orthographic'
}
const cameraConfig = this.nodeStorage.loadNodeProperty('Camera Config', {
cameraType: cameraState.cameraType,
fov:
this.camera instanceof THREE.PerspectiveCamera
? (this.camera as THREE.PerspectiveCamera).fov
: 75
})
cameraConfig.state = cameraState
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
})
}

View File

@@ -7,7 +7,6 @@ import {
export class LightingManager implements LightingManagerInterface {
lights: THREE.Light[] = []
currentIntensity: number = 3
private scene: THREE.Scene
private eventManager: EventManagerInterface
@@ -59,7 +58,6 @@ export class LightingManager implements LightingManagerInterface {
}
setLightIntensity(intensity: number): void {
this.currentIntensity = intensity
this.lights.forEach((light) => {
if (light instanceof THREE.DirectionalLight) {
if (light === this.lights[1]) {

View File

@@ -1,24 +1,9 @@
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraConfig,
LightConfig,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
type Load3DConfigurationSettings = {
loadFolder: string
modelWidget: IBaseWidget
cameraState?: any
width?: IBaseWidget
height?: IBaseWidget
bgImagePath?: string
}
class Load3DConfiguration {
constructor(private load3d: Load3d) {}
@@ -27,17 +12,22 @@ class Load3DConfiguration {
this.setupDefaultProperties()
}
configure(setting: Load3DConfigurationSettings) {
this.setupModelHandling(
setting.modelWidget,
setting.loadFolder,
setting.cameraState
)
this.setupTargetSize(setting.width, setting.height)
this.setupDefaultProperties(setting.bgImagePath)
configure(
loadFolder: 'input' | 'output',
modelWidget: IBaseWidget,
cameraState?: any,
width: IBaseWidget | null = null,
height: IBaseWidget | null = null
) {
this.setupModelHandling(modelWidget, loadFolder, cameraState)
this.setupTargetSize(width, height)
this.setupDefaultProperties()
}
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
private setupTargetSize(
width: IBaseWidget | null,
height: IBaseWidget | null
) {
if (width && height) {
this.load3d.setTargetSize(width.value as number, height.value as number)
@@ -51,7 +41,10 @@ class Load3DConfiguration {
}
}
private setupModelHandlingForSaveMesh(filePath: string, loadFolder: string) {
private setupModelHandlingForSaveMesh(
filePath: string,
loadFolder: 'input' | 'output'
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder)
if (filePath) {
@@ -61,7 +54,7 @@ class Load3DConfiguration {
private setupModelHandling(
modelWidget: IBaseWidget,
loadFolder: string,
loadFolder: 'input' | 'output',
cameraState?: any
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
@@ -72,119 +65,63 @@ class Load3DConfiguration {
onModelWidgetUpdate(modelWidget.value)
}
const originalCallback = modelWidget.callback
let currentValue = modelWidget.value
Object.defineProperty(modelWidget, 'value', {
get() {
return currentValue
},
set(newValue) {
currentValue = newValue
if (modelWidget.callback && newValue !== undefined && newValue !== '') {
modelWidget.callback(newValue)
}
},
enumerable: true,
configurable: true
})
modelWidget.callback = (value: string | number | boolean | object) => {
this.load3d.node.properties['Texture'] = undefined
onModelWidgetUpdate(value)
if (originalCallback) {
originalCallback(value)
}
}
}
private setupDefaultProperties(bgImagePath?: string) {
const sceneConfig = this.loadSceneConfig()
this.applySceneConfig(sceneConfig, bgImagePath)
private setupDefaultProperties() {
const cameraType = this.load3d.loadNodeProperty(
'Camera Type',
useSettingStore().get('Comfy.Load3D.CameraType')
)
this.load3d.toggleCamera(cameraType)
const cameraConfig = this.loadCameraConfig()
this.applyCameraConfig(cameraConfig)
const showGrid = this.load3d.loadNodeProperty(
'Show Grid',
useSettingStore().get('Comfy.Load3D.ShowGrid')
)
const lightConfig = this.loadLightConfig()
this.applyLightConfig(lightConfig)
this.load3d.toggleGrid(showGrid)
const showPreview = this.load3d.loadNodeProperty(
'Show Preview',
useSettingStore().get('Comfy.Load3D.ShowPreview')
)
this.load3d.togglePreview(showPreview)
const bgColor = this.load3d.loadNodeProperty(
'Background Color',
'#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
)
this.load3d.setBackgroundColor(bgColor)
const lightIntensity: number = Number(
this.load3d.loadNodeProperty(
'Light Intensity',
useSettingStore().get('Comfy.Load3D.LightIntensity')
)
)
this.load3d.setLightIntensity(lightIntensity)
const fov: number = Number(this.load3d.loadNodeProperty('FOV', 35))
this.load3d.setFOV(fov)
const backgroundImage = this.load3d.loadNodeProperty('Background Image', '')
this.load3d.setBackgroundImage(backgroundImage)
}
private loadSceneConfig(): SceneConfig {
const defaultConfig: SceneConfig = {
showGrid: useSettingStore().get('Comfy.Load3D.ShowGrid'),
backgroundColor:
'#' + useSettingStore().get('Comfy.Load3D.BackgroundColor'),
backgroundImage: ''
}
const config = this.load3d.loadNodeProperty('Scene Config', defaultConfig)
this.load3d.node.properties['Scene Config'] = config
return config
}
private loadCameraConfig(): CameraConfig {
const defaultConfig: CameraConfig = {
cameraType: useSettingStore().get('Comfy.Load3D.CameraType'),
fov: 35
}
const config = this.load3d.loadNodeProperty('Camera Config', defaultConfig)
this.load3d.node.properties['Camera Config'] = config
return config
}
private loadLightConfig(): LightConfig {
const defaultConfig: LightConfig = {
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
}
const config = this.load3d.loadNodeProperty('Light Config', defaultConfig)
this.load3d.node.properties['Light Config'] = config
return config
}
private loadModelConfig(): ModelConfig {
const defaultConfig: ModelConfig = {
upDirection: 'original',
materialMode: 'original'
}
const config = this.load3d.loadNodeProperty('Model Config', defaultConfig)
this.load3d.node.properties['Model Config'] = config
return config
}
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
this.load3d.toggleGrid(config.showGrid)
this.load3d.setBackgroundColor(config.backgroundColor)
if (config.backgroundImage) {
if (bgImagePath && bgImagePath != config.backgroundImage) {
return
}
this.load3d.setBackgroundImage(config.backgroundImage)
}
}
private applyCameraConfig(config: CameraConfig) {
this.load3d.toggleCamera(config.cameraType)
this.load3d.setFOV(config.fov)
if (config.state) {
this.load3d.setCameraState(config.state)
}
}
private applyLightConfig(config: LightConfig) {
this.load3d.setLightIntensity(config.intensity)
}
private applyModelConfig(config: ModelConfig) {
this.load3d.setUpDirection(config.upDirection)
this.load3d.setMaterialMode(config.materialMode)
}
private createModelUpdateHandler(loadFolder: string, cameraState?: any) {
private createModelUpdateHandler(
loadFolder: 'input' | 'output',
cameraState?: any
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value) return
@@ -202,8 +139,25 @@ class Load3DConfiguration {
await this.load3d.loadModel(modelUrl, filename)
const modelConfig = this.loadModelConfig()
this.applyModelConfig(modelConfig)
const upDirection = this.load3d.loadNodeProperty(
'Up Direction',
'original'
)
this.load3d.setUpDirection(upDirection)
const materialMode = this.load3d.loadNodeProperty(
'Material Mode',
'original'
)
this.load3d.setMaterialMode(materialMode)
const edgeThreshold: number = Number(
this.load3d.loadNodeProperty('Edge Threshold', 85)
)
this.load3d.setEdgeThreshold(edgeThreshold)
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {

View File

@@ -1,8 +1,8 @@
import * as THREE from 'three'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
import { EventManager } from './EventManager'
@@ -10,6 +10,7 @@ import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
import { NodeStorage } from './NodeStorage'
import { PreviewManager } from './PreviewManager'
import { RecordingManager } from './RecordingManager'
import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
@@ -28,7 +29,6 @@ class Load3d {
protected clock: THREE.Clock
protected animationFrameId: number | null = null
node: LGraphNode
private loadingPromise: Promise<void> | null = null
eventManager: EventManager
nodeStorage: NodeStorage
@@ -37,10 +37,10 @@ class Load3d {
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
previewManager: PreviewManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
animationManager: AnimationManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
@@ -62,7 +62,8 @@ class Load3d {
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
node: {} as LGraphNode
node: {} as LGraphNode,
inputSpec: {} as CustomInputSpec
}
) {
this.node = options.node || ({} as LGraphNode)
@@ -123,12 +124,27 @@ class Load3d {
this.nodeStorage
)
this.previewManager = new PreviewManager(
this.sceneManager.scene,
this.getActiveCamera.bind(this),
this.getControls.bind(this),
() => this.renderer,
this.eventManager,
this.sceneManager.backgroundScene,
this.sceneManager.backgroundCamera
)
if (options.disablePreview) {
this.previewManager.togglePreview(false)
}
this.modelManager = new SceneModelManager(
this.sceneManager.scene,
this.renderer,
this.eventManager,
this.getActiveCamera.bind(this),
this.setupCamera.bind(this)
this.setupCamera.bind(this),
options
)
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
@@ -138,18 +154,21 @@ class Load3d {
this.renderer,
this.eventManager
)
this.animationManager = new AnimationManager(this.eventManager)
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.loaderManager.init()
this.animationManager.init()
this.loaderManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
if (options && !options.inputSpec?.isPreview) {
this.previewManager.createCapturePreview(container)
this.previewManager.init()
}
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
@@ -234,6 +253,9 @@ class Load3d {
return this.eventManager
}
getNodeStorage(): NodeStorage {
return this.nodeStorage
}
getSceneManager(): SceneManager {
return this.sceneManager
}
@@ -249,6 +271,9 @@ class Load3d {
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getPreviewManager(): PreviewManager {
return this.previewManager
}
getLoaderManager(): LoaderManager {
return this.loaderManager
}
@@ -261,12 +286,15 @@ class Load3d {
forceRender(): void {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
@@ -280,18 +308,7 @@ class Load3d {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
const shouldMaintainAspectRatio =
(widthWidget && heightWidget) || this.isViewerMode
if (shouldMaintainAspectRatio) {
if (widthWidget && heightWidget) {
this.targetWidth = widthWidget.value as number
this.targetHeight = heightWidget.value as number
this.targetAspectRatio = this.targetWidth / this.targetHeight
}
if (this.isViewerMode) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
@@ -321,7 +338,6 @@ class Load3d {
const renderAspectRatio = renderWidth / renderHeight
this.cameraManager.updateAspectRatio(renderAspectRatio)
} else {
// Preview3D: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
@@ -364,12 +380,15 @@ class Load3d {
}
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
@@ -446,54 +465,44 @@ class Load3d {
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.previewManager.setPreviewBackgroundColor(color)
this.forceRender()
}
async setBackgroundImage(uploadPath: string): Promise<void> {
await this.sceneManager.setBackgroundImage(uploadPath)
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
if (
this.isViewerMode &&
this.sceneManager.backgroundTexture &&
this.sceneManager.backgroundMesh
) {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
const containerAspectRatio = containerWidth / containerHeight
// Calculate the actual render area based on target aspect ratio
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
const shouldMaintainAspectRatio =
(widthWidget && heightWidget) || this.isViewerMode
let renderWidth: number
let renderHeight: number
if (shouldMaintainAspectRatio) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
renderWidth,
renderHeight
)
if (containerAspectRatio > this.targetAspectRatio) {
renderHeight = containerHeight
renderWidth = renderHeight * this.targetAspectRatio
} else {
// For Preview3D mode without aspect ratio constraints
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
containerWidth,
containerHeight
)
renderWidth = containerWidth
renderHeight = renderWidth / this.targetAspectRatio
}
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
renderWidth,
renderHeight
)
}
this.forceRender()
@@ -502,6 +511,10 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
this.previewManager.setPreviewBackgroundColor(
this.sceneManager.currentBackgroundColor
)
this.forceRender()
}
@@ -543,49 +556,28 @@ class Load3d {
this.forceRender()
}
setEdgeThreshold(threshold: number): void {
this.modelManager.setEdgeThreshold(threshold)
this.forceRender()
}
setMaterialMode(mode: MaterialMode): void {
this.modelManager.setMaterialMode(mode)
this.forceRender()
}
async loadModel(url: string, originalFileName?: string): Promise<void> {
if (this.loadingPromise) {
try {
await this.loadingPromise
} catch (e) {}
}
this.loadingPromise = this._loadModelInternal(url, originalFileName)
return this.loadingPromise
}
private async _loadModelInternal(
url: string,
originalFileName?: string
): Promise<void> {
this.cameraManager.reset()
this.controlsManager.reset()
this.modelManager.clearModel()
this.animationManager.dispose()
this.modelManager.reset()
await this.loaderManager.loadModel(url, originalFileName)
// Auto-detect and setup animations if present
if (this.modelManager.currentModel) {
this.animationManager.setupModelAnimations(
this.modelManager.currentModel,
this.modelManager.originalModel
)
}
this.handleResize()
this.forceRender()
this.loadingPromise = null
}
clearModel(): void {
this.animationManager.dispose()
this.modelManager.clearModel()
this.forceRender()
}
@@ -600,10 +592,16 @@ class Load3d {
this.forceRender()
}
togglePreview(showPreview: boolean): void {
this.previewManager.togglePreview(showPreview)
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
this.previewManager.setTargetSize(width, height)
this.forceRender()
}
@@ -621,7 +619,7 @@ class Load3d {
}
handleResize(): void {
const parentElement = this.renderer?.domElement
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
@@ -631,20 +629,7 @@ class Load3d {
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
// Check if we have width/height widgets (Load3D nodes) or if it's viewer mode
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
const shouldMaintainAspectRatio =
(widthWidget && heightWidget) || this.isViewerMode
if (shouldMaintainAspectRatio) {
// Load3D or viewer mode: maintain aspect ratio
if (widthWidget && heightWidget) {
this.targetWidth = widthWidget.value as number
this.targetHeight = heightWidget.value as number
this.targetAspectRatio = this.targetWidth / this.targetHeight
}
if (this.isViewerMode) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
@@ -657,16 +642,16 @@ class Load3d {
renderHeight = renderWidth / this.targetAspectRatio
}
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(renderWidth, renderHeight)
this.sceneManager.handleResize(renderWidth, renderHeight)
} else {
// Preview3D: use container dimensions directly
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.renderer.setSize(containerWidth, containerHeight)
this.previewManager.handleResize()
this.forceRender()
}
@@ -681,10 +666,7 @@ class Load3d {
public async startRecording(): Promise<void> {
this.viewHelperManager.visibleViewHelper(false)
return this.recordingManager.startRecording(
this.targetWidth,
this.targetHeight
)
return this.recordingManager.startRecording()
}
public stopRecording(): void {
@@ -715,23 +697,6 @@ class Load3d {
this.recordingManager.clearRecording()
}
// Animation methods
public setAnimationSpeed(speed: number): void {
this.animationManager.setAnimationSpeed(speed)
}
public updateSelectedAnimation(index: number): void {
this.animationManager.updateSelectedAnimation(index)
}
public toggleAnimation(play?: boolean): void {
this.animationManager.toggleAnimation(play)
}
public hasAnimations(): boolean {
return this.animationManager.animationClips.length > 0
}
public remove(): void {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
@@ -755,10 +720,10 @@ class Load3d {
this.controlsManager.dispose()
this.lightingManager.dispose()
this.viewHelperManager.dispose()
this.previewManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()
this.recordingManager.dispose()
this.animationManager.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()

View File

@@ -0,0 +1,131 @@
import * as THREE from 'three'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { AnimationManager } from './AnimationManager'
import Load3d from './Load3d'
import { type Load3DOptions } from './interfaces'
class Load3dAnimation extends Load3d {
private animationManager: AnimationManager
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
node: {} as LGraphNode
}
) {
super(container, options)
this.animationManager = new AnimationManager(
this.eventManager,
this.getCurrentModel.bind(this)
)
this.animationManager.init()
this.overrideAnimationLoop()
}
private overrideAnimationLoop(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
if (!this.isActive()) {
return
}
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
}
animate()
}
override async loadModel(
url: string,
originalFileName?: string
): Promise<void> {
await super.loadModel(url, originalFileName)
if (this.modelManager.currentModel) {
this.animationManager.setupModelAnimations(
this.modelManager.currentModel,
this.modelManager.originalModel
)
}
}
override clearModel(): void {
this.animationManager.dispose()
super.clearModel()
}
updateAnimationList(): void {
this.animationManager.updateAnimationList()
}
setAnimationSpeed(speed: number): void {
this.animationManager.setAnimationSpeed(speed)
}
updateSelectedAnimation(index: number): void {
this.animationManager.updateSelectedAnimation(index)
}
toggleAnimation(play?: boolean): void {
this.animationManager.toggleAnimation(play)
}
get isAnimationPlaying(): boolean {
return this.animationManager.isAnimationPlaying
}
get animationSpeed(): number {
return this.animationManager.animationSpeed
}
get selectedAnimationIndex(): number {
return this.animationManager.selectedAnimationIndex
}
get animationClips(): THREE.AnimationClip[] {
return this.animationManager.animationClips
}
get animationActions(): THREE.AnimationAction[] {
return this.animationManager.animationActions
}
get currentAnimation(): THREE.AnimationMixer | null {
return this.animationManager.currentAnimation
}
override remove(): void {
this.animationManager.dispose()
super.remove()
}
}
export default Load3dAnimation

View File

@@ -0,0 +1,416 @@
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import {
type EventManagerInterface,
type PreviewManagerInterface
} from './interfaces'
export class PreviewManager implements PreviewManagerInterface {
previewCamera: THREE.Camera
previewContainer: HTMLDivElement = null!
showPreview: boolean = true
previewWidth: number = 120
private targetWidth: number = 1024
private targetHeight: number = 1024
private scene: THREE.Scene
private getActiveCamera: () => THREE.Camera
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
private getRenderer: () => THREE.WebGLRenderer
private previewBackgroundScene: THREE.Scene
private previewBackgroundCamera: THREE.OrthographicCamera
private previewBackgroundMesh: THREE.Mesh | null = null
private previewBackgroundTexture: THREE.Texture | null = null
private previewBackgroundColorMaterial: THREE.MeshBasicMaterial | null = null
private currentBackgroundColor: THREE.Color = new THREE.Color(0x282828)
constructor(
scene: THREE.Scene,
getActiveCamera: () => THREE.Camera,
getControls: () => OrbitControls,
getRenderer: () => THREE.WebGLRenderer,
eventManager: EventManagerInterface,
backgroundScene: THREE.Scene,
backgroundCamera: THREE.OrthographicCamera
) {
this.scene = scene
this.getActiveCamera = getActiveCamera
this.getControls = getControls
this.getRenderer = getRenderer
this.eventManager = eventManager
this.previewCamera = this.getActiveCamera().clone()
this.previewBackgroundScene = backgroundScene.clone()
this.previewBackgroundCamera = backgroundCamera.clone()
this.initPreviewBackgroundScene()
}
private initPreviewBackgroundScene(): void {
const planeGeometry = new THREE.PlaneGeometry(2, 2)
this.previewBackgroundColorMaterial = new THREE.MeshBasicMaterial({
color: this.currentBackgroundColor.clone(),
transparent: false,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.previewBackgroundMesh = new THREE.Mesh(
planeGeometry,
this.previewBackgroundColorMaterial
)
this.previewBackgroundMesh.position.set(0, 0, 0)
this.previewBackgroundScene.add(this.previewBackgroundMesh)
}
init(): void {}
dispose(): void {
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (this.previewBackgroundColorMaterial) {
this.previewBackgroundColorMaterial.dispose()
}
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.geometry.dispose()
if (this.previewBackgroundMesh.material instanceof THREE.Material) {
this.previewBackgroundMesh.material.dispose()
}
}
}
createCapturePreview(container: Element | HTMLElement): void {
this.previewContainer = document.createElement('div')
this.previewContainer.style.cssText = `
position: absolute;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.2);
display: block;
transition: border-color 0.1s ease;
`
const MIN_PREVIEW_WIDTH = 120
const MAX_PREVIEW_WIDTH = 240
this.previewContainer.addEventListener('wheel', (event) => {
event.preventDefault()
event.stopPropagation()
const delta = event.deltaY
const oldWidth = this.previewWidth
if (delta > 0) {
this.previewWidth = Math.max(MIN_PREVIEW_WIDTH, this.previewWidth - 10)
} else {
this.previewWidth = Math.min(MAX_PREVIEW_WIDTH, this.previewWidth + 10)
}
if (
oldWidth !== this.previewWidth &&
(this.previewWidth === MIN_PREVIEW_WIDTH ||
this.previewWidth === MAX_PREVIEW_WIDTH)
) {
this.flashPreviewBorder()
}
this.updatePreviewSize()
})
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
container.appendChild(this.previewContainer)
this.updatePreviewSize()
}
flashPreviewBorder(): void {
const originalBorder = this.previewContainer.style.border
const originalBoxShadow = this.previewContainer.style.boxShadow
this.previewContainer.style.border = '2px solid rgba(255, 255, 255, 0.8)'
this.previewContainer.style.boxShadow = '0 0 8px rgba(255, 255, 255, 0.5)'
setTimeout(() => {
this.previewContainer.style.border = originalBorder
this.previewContainer.style.boxShadow = originalBoxShadow
}, 100)
}
updatePreviewSize(): void {
if (!this.previewContainer) return
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewContainer.style.width = `${this.previewWidth}px`
this.previewContainer.style.height = `${previewHeight}px`
}
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null {
if (!this.showPreview || !this.previewContainer) {
return null
}
const renderer = this.getRenderer()
const canvas = renderer.domElement
const containerRect = this.previewContainer.getBoundingClientRect()
const canvasRect = canvas.getBoundingClientRect()
if (
containerRect.bottom < canvasRect.top ||
containerRect.top > canvasRect.bottom ||
containerRect.right < canvasRect.left ||
containerRect.left > canvasRect.right
) {
return null
}
const width = parseFloat(this.previewContainer.style.width)
const height = parseFloat(this.previewContainer.style.height)
const left = this.getRenderer().domElement.clientWidth - width
const bottom = 0
return { left, bottom, width, height }
}
renderPreview(): void {
const viewport = this.getPreviewViewport()
if (!viewport) return
const renderer = this.getRenderer()
const originalClearColor = renderer.getClearColor(new THREE.Color())
const originalClearAlpha = renderer.getClearAlpha()
if (
!this.previewCamera ||
(this.getActiveCamera() instanceof THREE.PerspectiveCamera &&
!(this.previewCamera instanceof THREE.PerspectiveCamera)) ||
(this.getActiveCamera() instanceof THREE.OrthographicCamera &&
!(this.previewCamera instanceof THREE.OrthographicCamera))
) {
this.previewCamera = this.getActiveCamera().clone()
}
this.previewCamera.position.copy(this.getActiveCamera().position)
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
const aspect = this.targetWidth / this.targetHeight
if (this.getActiveCamera() instanceof THREE.OrthographicCamera) {
const activeOrtho = this.getActiveCamera() as THREE.OrthographicCamera
const previewOrtho = this.previewCamera as THREE.OrthographicCamera
const frustumHeight =
(activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom
const frustumWidth = frustumHeight * aspect
previewOrtho.top = frustumHeight / 2
previewOrtho.left = -frustumWidth / 2
previewOrtho.right = frustumWidth / 2
previewOrtho.bottom = -frustumHeight / 2
previewOrtho.zoom = 1
previewOrtho.updateProjectionMatrix()
} else {
const activePerspective =
this.getActiveCamera() as THREE.PerspectiveCamera
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
previewPerspective.fov = activePerspective.fov
previewPerspective.zoom = activePerspective.zoom
previewPerspective.aspect = aspect
previewPerspective.updateProjectionMatrix()
}
this.previewCamera.lookAt(this.getControls().target)
renderer.setViewport(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
renderer.setScissor(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
renderer.setClearColor(0x000000, 0)
renderer.clear()
this.renderPreviewBackground(renderer)
renderer.render(this.scene, this.previewCamera)
renderer.setClearColor(originalClearColor, originalClearAlpha)
}
private renderPreviewBackground(renderer: THREE.WebGLRenderer): void {
if (this.previewBackgroundMesh) {
const currentToneMapping = renderer.toneMapping
const currentExposure = renderer.toneMappingExposure
renderer.toneMapping = THREE.NoToneMapping
renderer.render(this.previewBackgroundScene, this.previewBackgroundCamera)
renderer.toneMapping = currentToneMapping
renderer.toneMappingExposure = currentExposure
}
}
setPreviewBackgroundColor(color: string | number | THREE.Color): void {
this.currentBackgroundColor.set(color)
if (!this.previewBackgroundMesh || !this.previewBackgroundColorMaterial) {
this.initPreviewBackgroundScene()
}
this.previewBackgroundColorMaterial!.color.copy(this.currentBackgroundColor)
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.material = this.previewBackgroundColorMaterial!
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
this.previewBackgroundTexture = null
}
}
togglePreview(showPreview: boolean): void {
this.showPreview = showPreview
if (this.previewContainer) {
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
}
this.eventManager.emitEvent('showPreviewChange', showPreview)
}
setTargetSize(width: number, height: number): void {
const oldAspect = this.targetWidth / this.targetHeight
this.targetWidth = width
this.targetHeight = height
this.updatePreviewSize()
const newAspect = width / height
if (Math.abs(oldAspect - newAspect) > 0.001) {
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
width,
height
)
}
if (this.previewCamera) {
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
this.previewCamera.aspect = width / height
this.previewCamera.updateProjectionMatrix()
} else if (this.previewCamera instanceof THREE.OrthographicCamera) {
const frustumSize = 10
const aspect = width / height
this.previewCamera.left = (-frustumSize * aspect) / 2
this.previewCamera.right = (frustumSize * aspect) / 2
this.previewCamera.updateProjectionMatrix()
}
}
}
handleResize(): void {
this.updatePreviewSize()
}
updateBackgroundTexture(texture: THREE.Texture | null): void {
if (texture) {
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
this.previewBackgroundTexture = texture
if (this.previewBackgroundMesh) {
const imageMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
if (
this.previewBackgroundMesh.material instanceof THREE.Material &&
this.previewBackgroundMesh.material !==
this.previewBackgroundColorMaterial
) {
this.previewBackgroundMesh.material.dispose()
}
this.previewBackgroundMesh.material = imageMaterial
this.previewBackgroundMesh.position.set(0, 0, 0)
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
}
} else {
this.setPreviewBackgroundColor(this.currentBackgroundColor)
}
}
private updateBackgroundSize(
backgroundTexture: THREE.Texture | null,
backgroundMesh: THREE.Mesh | null,
targetWidth: number,
targetHeight: number
): void {
if (!backgroundTexture || !backgroundMesh) return
const material = backgroundMesh.material as THREE.MeshBasicMaterial
if (!material.map) return
const imageAspect =
backgroundTexture.image.width / backgroundTexture.image.height
const targetAspect = targetWidth / targetHeight
if (imageAspect > targetAspect) {
backgroundMesh.scale.set(imageAspect / targetAspect, 1, 1)
} else {
backgroundMesh.scale.set(1, targetAspect / imageAspect, 1)
}
material.needsUpdate = true
}
reset(): void {}
}

View File

@@ -16,8 +16,6 @@ export class RecordingManager {
private recordingStartTime: number = 0
private recordingDuration: number = 0
private recordingCanvas: HTMLCanvasElement | null = null
private recordingContext: CanvasRenderingContext2D | null = null
private animationFrameId: number | null = null
constructor(
scene: THREE.Scene,
@@ -52,70 +50,13 @@ export class RecordingManager {
this.scene.add(this.recordingIndicator)
}
public async startRecording(
targetWidth?: number,
targetHeight?: number
): Promise<void> {
public async startRecording(): Promise<void> {
if (this.isRecording) {
return
}
try {
const sourceCanvas = this.renderer.domElement
const sourceWidth = sourceCanvas.width
const sourceHeight = sourceCanvas.height
const recordWidth = targetWidth || sourceWidth
const recordHeight = targetHeight || sourceHeight
this.recordingCanvas = document.createElement('canvas')
this.recordingCanvas.width = recordWidth
this.recordingCanvas.height = recordHeight
this.recordingContext = this.recordingCanvas.getContext('2d', {
alpha: false
})
if (!this.recordingContext) {
throw new Error('Failed to get 2D context for recording canvas')
}
const sourceAspectRatio = sourceWidth / sourceHeight
const targetAspectRatio = recordWidth / recordHeight
let sx = 0,
sy = 0,
sw = sourceWidth,
sh = sourceHeight
if (Math.abs(sourceAspectRatio - targetAspectRatio) > 0.01) {
if (sourceAspectRatio > targetAspectRatio) {
sw = sourceHeight * targetAspectRatio
sx = (sourceWidth - sw) / 2
} else {
sh = sourceWidth / targetAspectRatio
sy = (sourceHeight - sh) / 2
}
}
const captureFrame = () => {
if (!this.isRecording || !this.recordingContext) {
return
}
this.recordingContext.drawImage(
sourceCanvas,
sx,
sy,
sw,
sh,
0,
0,
recordWidth,
recordHeight
)
this.animationFrameId = requestAnimationFrame(captureFrame)
}
this.recordingCanvas = this.renderer.domElement
this.recordingStream = this.recordingCanvas.captureStream(30)
@@ -141,11 +82,6 @@ export class RecordingManager {
this.isRecording = false
this.recordingStream = null
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
this.eventManager.emitEvent('recordingStopped', {
duration: this.recordingDuration,
hasRecording: this.recordedChunks.length > 0
@@ -160,8 +96,6 @@ export class RecordingManager {
this.isRecording = true
this.recordingStartTime = Date.now()
captureFrame()
this.eventManager.emitEvent('recordingStarted', null)
} catch (error) {
console.error('Error starting recording:', error)
@@ -176,18 +110,10 @@ export class RecordingManager {
this.recordingDuration = (Date.now() - this.recordingStartTime) / 1000
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
this.mediaRecorder.stop()
if (this.recordingStream) {
this.recordingStream.getTracks().forEach((track) => track.stop())
}
this.recordingCanvas = null
this.recordingContext = null
}
public getIsRecording(): boolean {
@@ -241,17 +167,9 @@ export class RecordingManager {
}
public dispose(): void {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
this.animationFrameId = null
}
this.stopRecording()
this.clearRecording()
this.recordingCanvas = null
this.recordingContext = null
if (this.recordingIndicator) {
this.scene.remove(this.recordingIndicator)
;(this.recordingIndicator.material as THREE.SpriteMaterial).map?.dispose()

View File

@@ -134,20 +134,9 @@ export class SceneManager implements SceneManagerInterface {
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
let type = 'input'
let pathParts = Load3dUtils.splitFilePath(uploadPath)
let subfolder = pathParts[0]
let filename = pathParts[1]
if (subfolder === 'temp') {
type = 'temp'
pathParts = ['', filename]
} else if (subfolder === 'output') {
type = 'output'
pathParts = ['', filename]
}
let imageUrl = Load3dUtils.getResourceURL(...pathParts, type)
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath)
)
if (!imageUrl.startsWith('/api')) {
imageUrl = '/api' + imageUrl
@@ -195,8 +184,8 @@ export class SceneManager implements SceneManagerInterface {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
this.renderer.domElement.clientWidth,
this.renderer.domElement.clientHeight
this.renderer.domElement.width,
this.renderer.domElement.height
)
this.eventManager.emitEvent('backgroundImageChange', uploadPath)
@@ -279,7 +268,7 @@ export class SceneManager implements SceneManagerInterface {
captureScene(
width: number,
height: number
): Promise<{ scene: string; mask: string; normal: string }> {
): Promise<{ scene: string; mask: string; normal: string; lineart: string }> {
return new Promise(async (resolve, reject) => {
try {
const originalWidth = this.renderer.domElement.width
@@ -370,9 +359,60 @@ export class SceneManager implements SceneManagerInterface {
}
})
let lineartModel: THREE.Group | null = null
const originalSceneVisible: Map<THREE.Object3D, boolean> = new Map()
this.scene.traverse((child) => {
if (child instanceof THREE.Group && child.name === 'lineartModel') {
lineartModel = child as THREE.Group
}
if (
child instanceof THREE.Mesh &&
!(child.parent?.name === 'lineartModel')
) {
originalSceneVisible.set(child, child.visible)
child.visible = false
}
})
this.renderer.setClearColor(0xffffff, 1)
this.renderer.clear()
if (lineartModel !== null) {
lineartModel = lineartModel as THREE.Group
const originalLineartVisibleMap: Map<THREE.Object3D, boolean> =
new Map()
lineartModel.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
originalLineartVisibleMap.set(child, child.visible)
child.visible = true
}
})
const originalLineartVisible = lineartModel.visible
lineartModel.visible = true
this.renderer.render(this.scene, this.getActiveCamera())
lineartModel.visible = originalLineartVisible
originalLineartVisibleMap.forEach((visible, object) => {
object.visible = visible
})
}
const lineartData = this.renderer.domElement.toDataURL('image/png')
originalSceneVisible.forEach((visible, object) => {
object.visible = visible
})
this.gridHelper.visible = gridVisible
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
@@ -384,7 +424,8 @@ export class SceneManager implements SceneManagerInterface {
resolve({
scene: sceneData,
mask: maskData,
normal: normalData
normal: normalData,
lineart: lineartData
})
} catch (error) {
reject(error)

View File

@@ -1,8 +1,18 @@
import * as THREE from 'three'
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial'
import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2'
import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry'
import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils'
import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial'
import { ConditionalEdgesGeometry } from './conditional-lines/ConditionalEdgesGeometry'
import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShader.js'
import { ConditionalLineMaterial } from './conditional-lines/Lines2/ConditionalLineMaterial'
import { ConditionalLineSegmentsGeometry } from './conditional-lines/Lines2/ConditionalLineSegmentsGeometry'
import {
type EventManagerInterface,
type Load3DOptions,
type MaterialMode,
type ModelManagerInterface,
type UpDirection
@@ -35,13 +45,25 @@ export class SceneModelManager implements ModelManagerInterface {
private eventManager: EventManagerInterface
private activeCamera: THREE.Camera
private setupCamera: (size: THREE.Vector3) => void
private lineartModel: THREE.Group
private createLineartModel: boolean = false
LIGHT_MODEL = 0xffffff
LIGHT_LINES = 0x455a64
conditionalModel: THREE.Object3D | null = null
edgesModel: THREE.Object3D | null = null
backgroundModel: THREE.Object3D | null = null
shadowModel: THREE.Object3D | null = null
depthModel: THREE.Object3D | null = null
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface,
getActiveCamera: () => THREE.Camera,
setupCamera: (size: THREE.Vector3) => void
setupCamera: (size: THREE.Vector3) => void,
options: Load3DOptions
) {
this.scene = scene
this.renderer = renderer
@@ -50,6 +72,14 @@ export class SceneModelManager implements ModelManagerInterface {
this.setupCamera = setupCamera
this.textureLoader = new THREE.TextureLoader()
if (
options &&
!options.inputSpec?.isPreview &&
!options.inputSpec?.isAnimation
) {
this.createLineartModel = true
}
this.normalMaterial = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
@@ -71,6 +101,10 @@ export class SceneModelManager implements ModelManagerInterface {
})
this.standardMaterial = this.createSTLMaterial()
this.lineartModel = new THREE.Group()
this.lineartModel.name = 'lineartModel'
}
init(): void {}
@@ -86,6 +120,8 @@ export class SceneModelManager implements ModelManagerInterface {
this.appliedTexture.dispose()
this.appliedTexture = null
}
this.disposeLineartModel()
}
createSTLMaterial(): THREE.MeshStandardMaterial {
@@ -98,6 +134,360 @@ export class SceneModelManager implements ModelManagerInterface {
})
}
disposeLineartModel(): void {
this.disposeEdgesModel()
this.disposeShadowModel()
this.disposeBackgroundModel()
this.disposeDepthModel()
this.disposeConditionalModel()
}
disposeEdgesModel(): void {
if (this.edgesModel) {
if (this.edgesModel.parent) {
this.edgesModel.parent.remove(this.edgesModel)
}
this.edgesModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose())
} else {
child.material.dispose()
}
}
})
}
}
initEdgesModel() {
this.disposeEdgesModel()
if (!this.currentModel) {
return
}
this.edgesModel = this.currentModel.clone()
this.lineartModel.add(this.edgesModel)
const meshes: THREE.Mesh[] = []
this.edgesModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push(child)
}
})
for (const key in meshes) {
const mesh = meshes[key]
const parent = mesh.parent
let lineGeom = new THREE.EdgesGeometry(mesh.geometry, 85)
const line = new THREE.LineSegments(
lineGeom,
new THREE.LineBasicMaterial({ color: this.LIGHT_LINES })
)
line.position.copy(mesh.position)
line.scale.copy(mesh.scale)
line.rotation.copy(mesh.rotation)
const thickLineGeom = new LineSegmentsGeometry().fromEdgesGeometry(
lineGeom
)
const thickLines = new LineSegments2(
thickLineGeom,
new LineMaterial({ color: this.LIGHT_LINES, linewidth: 13 })
)
thickLines.position.copy(mesh.position)
thickLines.scale.copy(mesh.scale)
thickLines.rotation.copy(mesh.rotation)
parent?.remove(mesh)
parent?.add(line)
parent?.add(thickLines)
}
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
}
setEdgeThreshold(threshold: number): void {
if (!this.edgesModel || !this.currentModel) {
return
}
const linesToRemove: THREE.Object3D[] = []
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.LineSegments ||
child instanceof LineSegments2
) {
linesToRemove.push(child)
}
})
for (const line of linesToRemove) {
if (line.parent) {
line.parent.remove(line)
}
}
const meshes: THREE.Mesh[] = []
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push(child)
}
})
for (const mesh of meshes) {
const meshClone = mesh.clone()
let lineGeom = new THREE.EdgesGeometry(meshClone.geometry, threshold)
const line = new THREE.LineSegments(
lineGeom,
new THREE.LineBasicMaterial({ color: this.LIGHT_LINES })
)
line.position.copy(mesh.position)
line.scale.copy(mesh.scale)
line.rotation.copy(mesh.rotation)
const thickLineGeom = new LineSegmentsGeometry().fromEdgesGeometry(
lineGeom
)
const thickLines = new LineSegments2(
thickLineGeom,
new LineMaterial({ color: this.LIGHT_LINES, linewidth: 13 })
)
thickLines.position.copy(mesh.position)
thickLines.scale.copy(mesh.scale)
thickLines.rotation.copy(mesh.rotation)
this.edgesModel.add(line)
this.edgesModel.add(thickLines)
}
this.edgesModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
this.eventManager.emitEvent('edgeThresholdChange', threshold)
}
disposeBackgroundModel(): void {
if (this.backgroundModel) {
if (this.backgroundModel.parent) {
this.backgroundModel.parent.remove(this.backgroundModel)
}
this.backgroundModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.dispose()
}
})
}
}
disposeShadowModel(): void {
if (this.shadowModel) {
if (this.shadowModel.parent) {
this.shadowModel.parent.remove(this.shadowModel)
}
this.shadowModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.dispose()
}
})
}
}
disposeDepthModel(): void {
if (this.depthModel) {
if (this.depthModel.parent) {
this.depthModel.parent.remove(this.depthModel)
}
this.depthModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.dispose()
}
})
}
}
disposeConditionalModel(): void {
if (this.conditionalModel) {
if (this.conditionalModel.parent) {
this.conditionalModel.parent.remove(this.conditionalModel)
}
this.conditionalModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.dispose()
}
})
}
}
initBackgroundModel() {
this.disposeBackgroundModel()
this.disposeShadowModel()
this.disposeDepthModel()
if (!this.currentModel) {
return
}
this.backgroundModel = this.currentModel.clone()
this.backgroundModel.visible = true
this.backgroundModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new THREE.MeshBasicMaterial({
color: this.LIGHT_MODEL
})
child.material.polygonOffset = true
child.material.polygonOffsetFactor = 1
child.material.polygonOffsetUnits = 1
child.renderOrder = 2
child.material.transparent = false
child.material.opacity = 0.25
}
})
this.lineartModel.add(this.backgroundModel)
this.shadowModel = this.currentModel.clone()
// TODO this has some error, need to fix later
this.shadowModel.visible = false
this.shadowModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new ColoredShadowMaterial({
color: this.LIGHT_MODEL,
shininess: 1.0
})
child.material.polygonOffset = true
child.material.polygonOffsetFactor = 1
child.material.polygonOffsetUnits = 1
child.receiveShadow = true
child.renderOrder = 2
}
})
this.lineartModel.add(this.shadowModel)
this.depthModel = this.currentModel.clone()
this.depthModel.visible = true
this.depthModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new THREE.MeshBasicMaterial({
color: this.LIGHT_MODEL
})
child.material.polygonOffset = true
child.material.polygonOffsetFactor = 1
child.material.polygonOffsetUnits = 1
child.material.colorWrite = false
child.renderOrder = 1
}
})
this.lineartModel.add(this.depthModel)
}
initConditionalModel() {
this.disposeConditionalModel()
if (!this.currentModel) {
return
}
this.conditionalModel = this.currentModel.clone()
this.lineartModel.add(this.conditionalModel)
this.conditionalModel.visible = true
const meshes: THREE.Mesh[] = []
this.conditionalModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push(child)
}
})
for (const key in meshes) {
const mesh = meshes[key]
const parent = mesh.parent
const mergedGeom = mesh.geometry.clone()
for (const key in mergedGeom.attributes) {
if (key !== 'position') {
mergedGeom.deleteAttribute(key)
}
}
const lineGeom = new ConditionalEdgesGeometry(mergeVertices(mergedGeom))
const material = new THREE.ShaderMaterial(ConditionalEdgesShader)
material.uniforms.diffuse.value.set(this.LIGHT_LINES)
const line = new THREE.LineSegments(lineGeom, material)
line.position.copy(mesh.position)
line.scale.copy(mesh.scale)
line.rotation.copy(mesh.rotation)
const thickLineGeom =
new ConditionalLineSegmentsGeometry().fromConditionalEdgesGeometry(
lineGeom
)
const conditionalLineMaterial = new ConditionalLineMaterial({
color: this.LIGHT_LINES,
linewidth: 2
})
const thickLines = new LineSegments2(
thickLineGeom,
conditionalLineMaterial
)
thickLines.position.copy(mesh.position)
thickLines.scale.copy(mesh.scale)
thickLines.rotation.copy(mesh.rotation)
parent?.remove(mesh)
parent?.add(line)
parent?.add(thickLines)
}
this.conditionalModel.traverse((child) => {
if (
child instanceof THREE.Mesh &&
child.material &&
child.material.resolution
) {
this.renderer.getSize(child.material.resolution)
child.material.resolution.multiplyScalar(window.devicePixelRatio)
child.material.linewidth = 1
}
})
}
setMaterialMode(mode: MaterialMode): void {
if (!this.currentModel || mode === this.materialMode) {
return
@@ -112,7 +502,11 @@ export class SceneModelManager implements ModelManagerInterface {
}
if (this.currentModel) {
this.currentModel.visible = true
this.currentModel.visible = mode !== 'lineart'
}
if (this.lineartModel) {
this.lineartModel.visible = mode === 'lineart'
}
this.currentModel.traverse((child) => {
@@ -255,7 +649,6 @@ export class SceneModelManager implements ModelManagerInterface {
reset(): void {
this.currentModel = null
this.originalModel = null
this.originalRotation = null
this.currentUpDirection = 'original'
this.setMaterialMode('original')
@@ -306,6 +699,20 @@ export class SceneModelManager implements ModelManagerInterface {
this.setupModelMaterials(model)
this.setupCamera(size)
if (this.createLineartModel) {
this.setupLineartModel()
}
}
setupLineartModel(): void {
this.scene.add(this.lineartModel)
this.initEdgesModel()
this.initBackgroundModel()
this.initConditionalModel()
this.lineartModel.visible = false
}
setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void {
@@ -323,6 +730,7 @@ export class SceneModelManager implements ModelManagerInterface {
if (this.originalRotation) {
this.currentModel.rotation.copy(this.originalRotation)
this.lineartModel.rotation.copy(this.originalRotation)
}
switch (direction) {
@@ -347,6 +755,8 @@ export class SceneModelManager implements ModelManagerInterface {
break
}
this.lineartModel.rotation.copy(this.currentModel.rotation)
this.eventManager.emitEvent('upDirectionChange', direction)
}
}

View File

@@ -74,7 +74,7 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
this.viewHelper.update(delta)
if (!this.viewHelper.animating) {
const cameraState = {
this.nodeStorage.storeNodeProperty('Camera Info', {
position: this.getActiveCamera().position.clone(),
target: this.getControls().target.clone(),
zoom:
@@ -85,20 +85,7 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
this.getActiveCamera() instanceof THREE.PerspectiveCamera
? 'perspective'
: 'orthographic'
}
const cameraConfig = this.nodeStorage.loadNodeProperty(
'Camera Config',
{
cameraType: cameraState.cameraType,
fov:
this.getActiveCamera() instanceof THREE.PerspectiveCamera
? (this.getActiveCamera() as THREE.PerspectiveCamera).fov
: 75
}
)
cameraConfig.state = cameraState
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
})
}
}
}

View File

@@ -0,0 +1,163 @@
/*
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
under MIT license
*/
import { Color, ShaderLib, ShaderMaterial, UniformsUtils } from 'three'
export class ColoredShadowMaterial extends ShaderMaterial {
get color() {
return this.uniforms.diffuse.value
}
get shadowColor() {
return this.uniforms.shadowColor.value
}
set shininess(v) {
this.uniforms.shininess.value = v
}
get shininess() {
return this.uniforms.shininess.value
}
constructor(options) {
super({
uniforms: UniformsUtils.merge([
ShaderLib.phong.uniforms,
{
shadowColor: {
value: new Color(0xff0000)
}
}
]),
vertexShader: `
#define PHONG
varying vec3 vViewPosition;
#ifndef FLAT_SHADED
varying vec3 vNormal;
#endif
#include <common>
#include <uv_pars_vertex>
#include <uv2_pars_vertex>
#include <displacementmap_pars_vertex>
#include <envmap_pars_vertex>
#include <color_pars_vertex>
#include <fog_pars_vertex>
#include <morphtarget_pars_vertex>
#include <skinning_pars_vertex>
#include <shadowmap_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
void main() {
#include <uv_vertex>
#include <uv2_vertex>
#include <color_vertex>
#include <beginnormal_vertex>
#include <morphnormal_vertex>
#include <skinbase_vertex>
#include <skinnormal_vertex>
#include <defaultnormal_vertex>
#ifndef FLAT_SHADED
vNormal = normalize( transformedNormal );
#endif
#include <begin_vertex>
#include <morphtarget_vertex>
#include <skinning_vertex>
#include <displacementmap_vertex>
#include <project_vertex>
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
vViewPosition = - mvPosition.xyz;
#include <worldpos_vertex>
#include <envmap_vertex>
#include <shadowmap_vertex>
#include <fog_vertex>
}
`,
fragmentShader: `
#define PHONG
uniform vec3 diffuse;
uniform vec3 emissive;
uniform vec3 specular;
uniform float shininess;
uniform float opacity;
uniform vec3 shadowColor;
#include <common>
#include <packing>
#include <dithering_pars_fragment>
#include <color_pars_fragment>
#include <uv_pars_fragment>
#include <uv2_pars_fragment>
#include <map_pars_fragment>
#include <alphamap_pars_fragment>
#include <aomap_pars_fragment>
#include <lightmap_pars_fragment>
#include <emissivemap_pars_fragment>
#include <envmap_common_pars_fragment>
#include <envmap_pars_fragment>
#include <cube_uv_reflection_fragment>
#include <fog_pars_fragment>
#include <bsdfs>
#include <lights_pars_begin>
#include <lights_phong_pars_fragment>
#include <shadowmap_pars_fragment>
#include <bumpmap_pars_fragment>
#include <normalmap_pars_fragment>
#include <specularmap_pars_fragment>
#include <logdepthbuf_pars_fragment>
#include <clipping_planes_pars_fragment>
void main() {
#include <clipping_planes_fragment>
vec4 diffuseColor = vec4( 1.0, 1.0, 1.0, opacity );
ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
vec3 totalEmissiveRadiance = emissive;
#include <logdepthbuf_fragment>
#include <map_fragment>
#include <color_fragment>
#include <alphamap_fragment>
#include <alphatest_fragment>
#include <specularmap_fragment>
#include <normal_fragment_begin>
#include <normal_fragment_maps>
#include <emissivemap_fragment>
#include <lights_phong_fragment>
#include <lights_fragment_begin>
#include <lights_fragment_maps>
#include <lights_fragment_end>
#include <aomap_fragment>
vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;
#include <envmap_fragment>
gl_FragColor = vec4( outgoingLight, diffuseColor.a );
#include <tonemapping_fragment>
#include <fog_fragment>
#include <premultiplied_alpha_fragment>
#include <dithering_fragment>
gl_FragColor.rgb = mix(
shadowColor.rgb,
diffuse.rgb,
min( gl_FragColor.r, 1.0 )
);
}
`
})
Object.defineProperties(this, {
opacity: {
set(v) {
this.uniforms.opacity.value = v
},
get() {
return this.uniforms.opacity.value
}
}
})
this.setValues(options)
this.lights = true
}
}

View File

@@ -0,0 +1,126 @@
/*
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
under MIT license
*/
import { BufferAttribute, BufferGeometry, Triangle, Vector3 } from 'three'
const vec0 = new Vector3()
const vec1 = new Vector3()
const vec2 = new Vector3()
const vec3 = new Vector3()
const vec4 = new Vector3()
const triangle0 = new Triangle()
const triangle1 = new Triangle()
const normal0 = new Vector3()
const normal1 = new Vector3()
export class ConditionalEdgesGeometry extends BufferGeometry {
constructor(geometry) {
super()
const edgeInfo = {}
const position = geometry.attributes.position
let index
if (geometry.index) {
index = geometry.index
} else {
const arr = new Array(position.count / 3).fill().map((_, i) => i)
index = new BufferAttribute(new Uint32Array(arr), 1, false)
}
for (let i = 0, l = index.count; i < l; i += 3) {
const indices = [index.getX(i + 0), index.getX(i + 1), index.getX(i + 2)]
for (let j = 0; j < 3; j++) {
const index0 = indices[j]
const index1 = indices[(j + 1) % 3]
const hash = `${index0}_${index1}`
const reverseHash = `${index1}_${index0}`
if (reverseHash in edgeInfo) {
edgeInfo[reverseHash].controlIndex1 = indices[(j + 2) % 3]
edgeInfo[reverseHash].tri1 = i / 3
} else {
edgeInfo[hash] = {
index0,
index1,
controlIndex0: indices[(j + 2) % 3],
controlIndex1: null,
tri0: i / 3,
tri1: null
}
}
}
}
const edgePositions = []
const edgeDirections = []
const edgeControl0 = []
const edgeControl1 = []
for (const key in edgeInfo) {
const { index0, index1, controlIndex0, controlIndex1, tri0, tri1 } =
edgeInfo[key]
if (controlIndex1 === null) {
continue
}
triangle0.a.fromBufferAttribute(position, index.getX(tri0 * 3 + 0))
triangle0.b.fromBufferAttribute(position, index.getX(tri0 * 3 + 1))
triangle0.c.fromBufferAttribute(position, index.getX(tri0 * 3 + 2))
triangle1.a.fromBufferAttribute(position, index.getX(tri1 * 3 + 0))
triangle1.b.fromBufferAttribute(position, index.getX(tri1 * 3 + 1))
triangle1.c.fromBufferAttribute(position, index.getX(tri1 * 3 + 2))
triangle0.getNormal(normal0).normalize()
triangle1.getNormal(normal1).normalize()
if (normal0.dot(normal1) < 0.01) {
continue
}
// positions
vec0.fromBufferAttribute(position, index0)
vec1.fromBufferAttribute(position, index1)
// direction
vec2.subVectors(vec0, vec1)
// control positions
vec3.fromBufferAttribute(position, controlIndex0)
vec4.fromBufferAttribute(position, controlIndex1)
// create arrays
edgePositions.push(vec0.x, vec0.y, vec0.z)
edgeDirections.push(vec2.x, vec2.y, vec2.z)
edgeControl0.push(vec3.x, vec3.y, vec3.z)
edgeControl1.push(vec4.x, vec4.y, vec4.z)
edgePositions.push(vec1.x, vec1.y, vec1.z)
edgeDirections.push(vec2.x, vec2.y, vec2.z)
edgeControl0.push(vec3.x, vec3.y, vec3.z)
edgeControl1.push(vec4.x, vec4.y, vec4.z)
}
this.setAttribute(
'position',
new BufferAttribute(new Float32Array(edgePositions), 3, false)
)
this.setAttribute(
'direction',
new BufferAttribute(new Float32Array(edgeDirections), 3, false)
)
this.setAttribute(
'control0',
new BufferAttribute(new Float32Array(edgeControl0), 3, false)
)
this.setAttribute(
'control1',
new BufferAttribute(new Float32Array(edgeControl1), 3, false)
)
}
}

View File

@@ -0,0 +1,96 @@
/*
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
under MIT license
*/
import { Color } from 'three'
export const ConditionalEdgesShader = {
uniforms: {
diffuse: {
value: new Color()
},
opacity: {
value: 1.0
}
},
vertexShader: /* glsl */ `
attribute vec3 control0;
attribute vec3 control1;
attribute vec3 direction;
#include <common>
#include <color_pars_vertex>
#include <fog_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
void main() {
#include <color_vertex>
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * mvPosition;
// Transform the line segment ends and control points into camera clip space
vec4 c0 = projectionMatrix * modelViewMatrix * vec4( control0, 1.0 );
vec4 c1 = projectionMatrix * modelViewMatrix * vec4( control1, 1.0 );
vec4 p0 = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
vec4 p1 = projectionMatrix * modelViewMatrix * vec4( position + direction, 1.0 );
c0 /= c0.w;
c1 /= c1.w;
p0 /= p0.w;
p1 /= p1.w;
// Get the direction of the segment and an orthogonal vector
vec2 dir = p1.xy - p0.xy;
vec2 norm = vec2( -dir.y, dir.x );
// Get control point directions from the line
vec2 c0dir = c0.xy - p1.xy;
vec2 c1dir = c1.xy - p1.xy;
// If the vectors to the controls points are pointed in different directions away
// from the line segment then the line should not be drawn.
float d0 = dot( normalize( norm ), normalize( c0dir ) );
float d1 = dot( normalize( norm ), normalize( c1dir ) );
float discardFlag = float( sign( d0 ) != sign( d1 ) );
gl_Position = discardFlag > 0.5 ? c0 : gl_Position;
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
#include <fog_vertex>
}
`,
fragmentShader: /* glsl */ `
uniform vec3 diffuse;
uniform float opacity;
#include <common>
#include <color_pars_fragment>
#include <fog_pars_fragment>
#include <logdepthbuf_pars_fragment>
#include <clipping_planes_pars_fragment>
void main() {
#include <clipping_planes_fragment>
vec3 outgoingLight = vec3( 0.0 );
vec4 diffuseColor = vec4( diffuse, opacity );
#include <logdepthbuf_fragment>
#include <color_fragment>
outgoingLight = diffuseColor.rgb; // simple shader
gl_FragColor = vec4( outgoingLight, diffuseColor.a );
#include <tonemapping_fragment>
#include <fog_fragment>
#include <premultiplied_alpha_fragment>
}
`
}

View File

@@ -0,0 +1,379 @@
/*
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
under MIT license
*/
import { ShaderMaterial, UniformsLib, UniformsUtils, Vector2 } from 'three'
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial'
/**
* parameters = {
* color: <hex>,
* linewidth: <float>,
* dashed: <boolean>,
* dashScale: <float>,
* dashSize: <float>,
* gapSize: <float>,
* resolution: <Vector2>, // to be set by renderer
* }
*/
const uniforms = {
linewidth: { value: 1 },
resolution: { value: new Vector2(1, 1) },
dashScale: { value: 1 },
dashSize: { value: 1 },
gapSize: { value: 1 }, // todo FIX - maybe change to totalSize
opacity: { value: 1 }
}
const shader = {
uniforms: UniformsUtils.merge([
UniformsLib.common,
UniformsLib.fog,
uniforms
]),
vertexShader: /* glsl */ `
#include <common>
#include <color_pars_vertex>
#include <fog_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
uniform float linewidth;
uniform vec2 resolution;
attribute vec3 control0;
attribute vec3 control1;
attribute vec3 direction;
attribute vec3 instanceStart;
attribute vec3 instanceEnd;
attribute vec3 instanceColorStart;
attribute vec3 instanceColorEnd;
varying vec2 vUv;
#ifdef USE_DASH
uniform float dashScale;
attribute float instanceDistanceStart;
attribute float instanceDistanceEnd;
varying float vLineDistance;
#endif
void trimSegment( const in vec4 start, inout vec4 end ) {
// trim end segment so it terminates between the camera plane and the near plane
// conservative estimate of the near plane
float a = projectionMatrix[ 2 ][ 2 ]; // 3nd entry in 3th column
float b = projectionMatrix[ 3 ][ 2 ]; // 3nd entry in 4th column
float nearEstimate = - 0.5 * b / a;
float alpha = ( nearEstimate - start.z ) / ( end.z - start.z );
end.xyz = mix( start.xyz, end.xyz, alpha );
}
void main() {
#ifdef USE_COLOR
vColor.xyz = ( position.y < 0.5 ) ? instanceColorStart : instanceColorEnd;
#endif
#ifdef USE_DASH
vLineDistance = ( position.y < 0.5 ) ? dashScale * instanceDistanceStart : dashScale * instanceDistanceEnd;
#endif
float aspect = resolution.x / resolution.y;
vUv = uv;
// camera space
vec4 start = modelViewMatrix * vec4( instanceStart, 1.0 );
vec4 end = modelViewMatrix * vec4( instanceEnd, 1.0 );
// special case for perspective projection, and segments that terminate either in, or behind, the camera plane
// clearly the gpu firmware has a way of addressing this issue when projecting into ndc space
// but we need to perform ndc-space calculations in the shader, so we must address this issue directly
// perhaps there is a more elegant solution -- WestLangley
bool perspective = ( projectionMatrix[ 2 ][ 3 ] == - 1.0 ); // 4th entry in the 3rd column
if ( perspective ) {
if ( start.z < 0.0 && end.z >= 0.0 ) {
trimSegment( start, end );
} else if ( end.z < 0.0 && start.z >= 0.0 ) {
trimSegment( end, start );
}
}
// clip space
vec4 clipStart = projectionMatrix * start;
vec4 clipEnd = projectionMatrix * end;
// ndc space
vec2 ndcStart = clipStart.xy / clipStart.w;
vec2 ndcEnd = clipEnd.xy / clipEnd.w;
// direction
vec2 dir = ndcEnd - ndcStart;
// account for clip-space aspect ratio
dir.x *= aspect;
dir = normalize( dir );
// perpendicular to dir
vec2 offset = vec2( dir.y, - dir.x );
// undo aspect ratio adjustment
dir.x /= aspect;
offset.x /= aspect;
// sign flip
if ( position.x < 0.0 ) offset *= - 1.0;
// endcaps
if ( position.y < 0.0 ) {
offset += - dir;
} else if ( position.y > 1.0 ) {
offset += dir;
}
// adjust for linewidth
offset *= linewidth;
// adjust for clip-space to screen-space conversion // maybe resolution should be based on viewport ...
offset /= resolution.y;
// select end
vec4 clip = ( position.y < 0.5 ) ? clipStart : clipEnd;
// back to clip space
offset *= clip.w;
clip.xy += offset;
gl_Position = clip;
vec4 mvPosition = ( position.y < 0.5 ) ? start : end; // this is an approximation
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
#include <fog_vertex>
// conditional logic
// Transform the line segment ends and control points into camera clip space
vec4 c0 = projectionMatrix * modelViewMatrix * vec4( control0, 1.0 );
vec4 c1 = projectionMatrix * modelViewMatrix * vec4( control1, 1.0 );
vec4 p0 = projectionMatrix * modelViewMatrix * vec4( instanceStart, 1.0 );
vec4 p1 = projectionMatrix * modelViewMatrix * vec4( instanceStart + direction, 1.0 );
c0 /= c0.w;
c1 /= c1.w;
p0 /= p0.w;
p1 /= p1.w;
// Get the direction of the segment and an orthogonal vector
vec2 segDir = p1.xy - p0.xy;
vec2 norm = vec2( - segDir.y, segDir.x );
// Get control point directions from the line
vec2 c0dir = c0.xy - p1.xy;
vec2 c1dir = c1.xy - p1.xy;
// If the vectors to the controls points are pointed in different directions away
// from the line segment then the line should not be drawn.
float d0 = dot( normalize( norm ), normalize( c0dir ) );
float d1 = dot( normalize( norm ), normalize( c1dir ) );
float discardFlag = float( sign( d0 ) != sign( d1 ) );
gl_Position = discardFlag > 0.5 ? c0 : gl_Position;
// end conditional line logic
}
`,
fragmentShader: /* glsl */ `
uniform vec3 diffuse;
uniform float opacity;
#ifdef USE_DASH
uniform float dashSize;
uniform float gapSize;
#endif
varying float vLineDistance;
#include <common>
#include <color_pars_fragment>
#include <fog_pars_fragment>
#include <logdepthbuf_pars_fragment>
#include <clipping_planes_pars_fragment>
varying vec2 vUv;
void main() {
#include <clipping_planes_fragment>
#ifdef USE_DASH
if ( vUv.y < - 1.0 || vUv.y > 1.0 ) discard; // discard endcaps
if ( mod( vLineDistance, dashSize + gapSize ) > dashSize ) discard; // todo - FIX
#endif
if ( abs( vUv.y ) > 1.0 ) {
float a = vUv.x;
float b = ( vUv.y > 0.0 ) ? vUv.y - 1.0 : vUv.y + 1.0;
float len2 = a * a + b * b;
if ( len2 > 1.0 ) discard;
}
vec4 diffuseColor = vec4( diffuse, opacity );
#include <logdepthbuf_fragment>
#include <color_fragment>
gl_FragColor = vec4( diffuseColor.rgb, diffuseColor.a );
#include <tonemapping_fragment>
#include <fog_fragment>
#include <premultiplied_alpha_fragment>
}
`
}
class ConditionalLineMaterial extends LineMaterial {
constructor(parameters) {
super({
type: 'ConditionalLineMaterial',
uniforms: UniformsUtils.clone(shader.uniforms),
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader,
clipping: true // required for clipping support
})
this.dashed = false
Object.defineProperties(this, {
color: {
enumerable: true,
get: function () {
return this.uniforms.diffuse.value
},
set: function (value) {
this.uniforms.diffuse.value = value
}
},
linewidth: {
enumerable: true,
get: function () {
return this.uniforms.linewidth.value
},
set: function (value) {
this.uniforms.linewidth.value = value
}
},
dashScale: {
enumerable: true,
get: function () {
return this.uniforms.dashScale.value
},
set: function (value) {
this.uniforms.dashScale.value = value
}
},
dashSize: {
enumerable: true,
get: function () {
return this.uniforms.dashSize.value
},
set: function (value) {
this.uniforms.dashSize.value = value
}
},
gapSize: {
enumerable: true,
get: function () {
return this.uniforms.gapSize.value
},
set: function (value) {
this.uniforms.gapSize.value = value
}
},
opacity: {
enumerable: true,
get: function () {
return this.uniforms.opacity.value
},
set: function (value) {
this.uniforms.opacity.value = value
}
},
resolution: {
enumerable: true,
get: function () {
return this.uniforms.resolution.value
},
set: function (value) {
this.uniforms.resolution.value.copy(value)
}
}
})
this.setValues(parameters)
}
}
ConditionalLineMaterial.prototype.isConditionalLineMaterial = true
export { ConditionalLineMaterial }

View File

@@ -0,0 +1,43 @@
/*
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
under MIT license
*/
import * as THREE from 'three'
import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'
export class ConditionalLineSegmentsGeometry extends LineSegmentsGeometry {
fromConditionalEdgesGeometry(geometry) {
super.fromEdgesGeometry(geometry)
const { direction, control0, control1 } = geometry.attributes
this.setAttribute(
'direction',
new THREE.InterleavedBufferAttribute(
new THREE.InstancedInterleavedBuffer(direction.array, 6, 1),
3,
0
)
)
this.setAttribute(
'control0',
new THREE.InterleavedBufferAttribute(
new THREE.InstancedInterleavedBuffer(control0.array, 6, 1),
3,
0
)
)
this.setAttribute(
'control1',
new THREE.InterleavedBufferAttribute(
new THREE.InstancedInterleavedBuffer(control1.array, 6, 1),
3,
0
)
)
return this
}
}

View File

@@ -10,7 +10,16 @@ import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
export type MaterialMode = 'original' | 'normal' | 'wireframe' | 'depth'
export type Load3DNodeType = 'Load3D' | 'Preview3D'
export type Load3DAnimationNodeType = 'Load3DAnimation' | 'Preview3DAnimation'
export type MaterialMode =
| 'original'
| 'normal'
| 'wireframe'
| 'depth'
| 'lineart'
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
@@ -21,27 +30,6 @@ export interface CameraState {
cameraType: CameraType
}
export interface SceneConfig {
showGrid: boolean
backgroundColor: string
backgroundImage?: string
}
export interface ModelConfig {
upDirection: UpDirection
materialMode: MaterialMode
}
export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
}
export interface LightConfig {
intensity: number
}
export interface EventCallback {
(data?: any): void
}
@@ -57,6 +45,7 @@ export interface CaptureResult {
scene: string
mask: string
normal: string
lineart: string
}
interface BaseManager {
@@ -112,6 +101,26 @@ export interface ViewHelperManagerInterface extends BaseManager {
handleResize(): void
}
export interface PreviewManagerInterface extends BaseManager {
previewCamera: THREE.Camera
previewContainer: HTMLDivElement
showPreview: boolean
previewWidth: number
createCapturePreview(container: Element | HTMLElement): void
updatePreviewSize(): void
togglePreview(showPreview: boolean): void
setTargetSize(width: number, height: number): void
handleResize(): void
updateBackgroundTexture(texture: THREE.Texture | null): void
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null
renderPreview(): void
}
export interface EventManagerInterface {
addEventListener(event: string, callback: EventCallback): void
removeEventListener(event: string, callback: EventCallback): void
@@ -176,11 +185,3 @@ export interface LoaderManagerInterface {
dispose(): void
loadModel(url: string, originalFileName?: string): Promise<void>
}
export const SUPPORTED_EXTENSIONS = new Set([
'.gltf',
'.glb',
'.obj',
'.fbx',
'.stl'
])

View File

@@ -1,7 +1,6 @@
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -11,12 +10,6 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3D',
isPreview: true
}
useExtensionService().registerExtension({
name: 'Comfy.SaveGLB',
@@ -30,6 +23,13 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
PREVIEW_3D(node) {
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3D',
isAnimation: false,
isPreview: true
}
const widget = new ComponentWidgetImpl({
node,
name: inputSpec.name,
@@ -38,8 +38,6 @@ useExtensionService().registerExtension({
options: {}
})
widget.type = 'load3D'
addWidget(node, widget)
return { widget }
@@ -73,19 +71,19 @@ useExtensionService().registerExtension({
const fileInfo = message['3d'][0]
useLoad3d(node).waitForLoad3d((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'image')
const load3d = useLoad3dService().getLoad3d(node)
if (load3d && modelWidget) {
const filePath = fileInfo['subfolder'] + '/' + fileInfo['filename']
const modelWidget = node.widgets?.find((w) => w.name === 'image')
modelWidget.value = filePath
if (load3d && modelWidget) {
const filePath = fileInfo['subfolder'] + '/' + fileInfo['filename']
const config = new Load3DConfiguration(load3d)
modelWidget.value = filePath
config.configureForSaveMesh(fileInfo['type'], filePath)
}
})
const config = new Load3DConfiguration(load3d)
config.configureForSaveMesh(fileInfo['type'], filePath)
}
}
}
})

View File

@@ -305,10 +305,6 @@
"Workspace_ToggleFocusMode": {
"label": "Toggle Focus Mode"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Toggle Assets Sidebar",
"tooltip": "Assets"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Toggle Model Library Sidebar",
"tooltip": "Model Library"

View File

@@ -478,6 +478,8 @@
"cannotWrite": "Unable to write to the selected path",
"insufficientFreeSpace": "Insufficient space - minimum free space",
"isOneDrive": "OneDrive is not supported. Please install ComfyUI in another location.",
"insideAppInstallDir": "This folder is inside the ComfyUI Desktop application bundle and will be deleted during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
"insideUpdaterCache": "This folder is inside the ComfyUI updater cache, which is cleared on every update. Select a different location for your data.",
"nonDefaultDrive": "Please install ComfyUI on your system drive (eg. C:\\). Drives with different file systems may cause unpredicable issues. Models and other files can be stored on other drives after installation.",
"parentMissing": "Path does not exist - create the containing directory first",
"unhandledError": "Unknown error",
@@ -990,7 +992,6 @@
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
"Focus Mode": "Focus Mode",
"Assets": "Assets",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Queue Panel": "Queue Panel",
@@ -1045,7 +1046,7 @@
"UV": "UV",
"ContextMenu": "Context Menu",
"Reroute": "Reroute",
"Load 3D": "Load 3D & Animation",
"Load 3D": "Load 3D",
"Camera": "Camera",
"Scene": "Scene",
"3D": "3D",
@@ -1057,8 +1058,7 @@
"3DViewer": "3DViewer",
"Vue Nodes": "Vue Nodes",
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits",
"VueNodes": "Vue Nodes"
"PlanCredits": "Plan & Credits"
},
"serverConfigItems": {
"listen": {
@@ -1359,6 +1359,14 @@
"taskFailed": "Task failed to run.",
"cannotContinue": "Unable to continue - errors remain",
"defaultDescription": "An error occurred while running a maintenance task."
},
"unsafeMigration": {
"title": "Unsafe install location detected",
"generic": "Your current ComfyUI base path is in a location that may be deleted or modified during updates. To avoid data loss, move it to a safe folder.",
"appInstallDir": "Your base path is inside the ComfyUI Desktop application bundle. This folder may be deleted or overwritten during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
"updaterCache": "Your base path is inside the ComfyUI updater cache, which is cleared on each update. Choose a different location for your data.",
"oneDrive": "Your base path is on OneDrive, which can cause sync issues and accidental data loss. Choose a local folder that is not managed by OneDrive.",
"action": "Use the \"Base path\" maintenance task below to move ComfyUI to a safe location."
}
},
"missingModelsDialog": {
@@ -1428,17 +1436,18 @@
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode...",
"edgeThreshold": "Edge Threshold",
"export": "Export",
"exportModel": "Export Model",
"exportingModel": "Exporting model...",
"reloadingModel": "Reloading model...",
"uploadTexture": "Upload Texture",
"applyingTexture": "Applying Texture...",
"materialModes": {
"normal": "Normal",
"wireframe": "Wireframe",
"original": "Original",
"depth": "Depth"
"depth": "Depth",
"lineart": "Lineart"
},
"upDirections": {
"original": "Original"
@@ -1464,10 +1473,7 @@
"exportSettings": "Export Settings",
"modelSettings": "Model Settings"
},
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
"uploadingModel": "Uploading 3D model..."
"openIn3DViewer": "Open in 3D Viewer"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
@@ -1509,10 +1515,7 @@
"nothingSelected": "Nothing selected",
"cannotCreateSubgraph": "Cannot create subgraph",
"failedToConvertToSubgraph": "Failed to convert items to subgraph",
"failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer",
"failedToLoadBackgroundImage": "Failed to load background image",
"failedToLoadModel": "Failed to load 3D model",
"modelLoadedSuccessfully": "3D model loaded successfully"
"failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer"
},
"auth": {
"apiKey": {
@@ -1655,7 +1658,6 @@
},
"subscription": {
"title": "Subscription",
"titleUnsubscribed": "Subscribe to Comfy Cloud",
"comfyCloud": "Comfy Cloud",
"beta": "BETA",
"perMonth": "USD / month",
@@ -1688,9 +1690,7 @@
"subscribe": "Subscribe"
},
"subscribeToRun": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"partnerNodesCredits": "Partner Nodes credits"
"subscribeNow": "Subscribe Now"
},
"userSettings": {
"title": "User Settings",
@@ -1838,4 +1838,4 @@
"message": "Nodes just got a new look and feel",
"tryItOut": "Try it out"
}
}
}

View File

@@ -5158,7 +5158,7 @@
},
"LumaConceptsNode": {
"display_name": "Luma Concepts",
"description": "Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.",
"description": "Holds one or more Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.",
"inputs": {
"concept1": {
"name": "concept1"
@@ -5462,7 +5462,7 @@
},
"MinimaxImageToVideoNode": {
"display_name": "MiniMax Image to Video",
"description": "Generates videos synchronously based on an image and prompt, and optional parameters.",
"description": "Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.",
"inputs": {
"image": {
"name": "image",
@@ -5492,7 +5492,7 @@
},
"MinimaxTextToVideoNode": {
"display_name": "MiniMax Text to Video",
"description": "Generates videos synchronously based on a prompt, and optional parameters.",
"description": "Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API.",
"inputs": {
"prompt_text": {
"name": "prompt_text",
@@ -9027,8 +9027,7 @@
},
"outputs": {
"0": {
"name": "recraft_color",
"tooltip": null
"name": "recraft_color"
}
}
},
@@ -9045,8 +9044,7 @@
},
"outputs": {
"0": {
"name": "recraft_controls",
"tooltip": null
"name": "recraft_controls"
}
}
},
@@ -9057,11 +9055,6 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftCrispUpscaleNode": {
@@ -9071,11 +9064,6 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftImageInpaintingNode": {
@@ -9110,11 +9098,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftImageToImageNode": {
@@ -9154,11 +9137,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftRemoveBackgroundNode": {
@@ -9168,14 +9146,6 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"RecraftReplaceBackgroundNode": {
@@ -9207,11 +9177,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftStyleV3DigitalIllustration": {
@@ -9224,8 +9189,7 @@
},
"outputs": {
"0": {
"name": "recraft_style",
"tooltip": null
"name": "recraft_style"
}
}
},
@@ -9240,8 +9204,7 @@
},
"outputs": {
"0": {
"name": "recraft_style",
"tooltip": null
"name": "recraft_style"
}
}
},
@@ -9255,8 +9218,7 @@
},
"outputs": {
"0": {
"name": "recraft_style",
"tooltip": null
"name": "recraft_style"
}
}
},
@@ -9270,8 +9232,7 @@
},
"outputs": {
"0": {
"name": "recraft_style",
"tooltip": null
"name": "recraft_style"
}
}
},
@@ -9309,11 +9270,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftTextToVectorNode": {
@@ -9350,11 +9306,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
@@ -9364,11 +9315,6 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ReferenceLatent": {
@@ -10295,38 +10241,6 @@
}
}
},
"ScaleROPE": {
"display_name": "ScaleROPE",
"description": "Scale and shift the ROPE of the model.",
"inputs": {
"model": {
"name": "model"
},
"scale_x": {
"name": "scale_x"
},
"shift_x": {
"name": "shift_x"
},
"scale_y": {
"name": "scale_y"
},
"shift_y": {
"name": "shift_y"
},
"scale_t": {
"name": "scale_t"
},
"shift_t": {
"name": "shift_t"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SD_4XUpscale_Conditioning": {
"display_name": "SD_4XUpscale_Conditioning",
"inputs": {

View File

@@ -364,14 +364,6 @@
"Comfy_Validation_Workflows": {
"name": "Validate workflows"
},
"Comfy_VueNodes_AutoScaleLayout": {
"name": "Auto-scale layout (Vue nodes)",
"tooltip": "Automatically scale node positions when switching to Vue rendering to prevent overlap"
},
"Comfy_VueNodes_Enabled": {
"name": "Modern Node Design (Vue Nodes)",
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."
},
"Comfy_WidgetControlMode": {
"name": "Widget control mode",
"tooltip": "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",

View File

@@ -5,17 +5,9 @@
:loading="isLoading"
:disabled="isPolling"
severity="primary"
:style="
variant === 'gradient'
? {
background: 'var(--color-subscription-button-gradient)',
color: 'var(--color-white)'
}
: undefined
"
:pt="{
root: {
class: rootClass
class: 'w-full font-bold'
}
}"
@click="handleSubscribe"
@@ -24,29 +16,22 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, ref } from 'vue'
import { onBeforeUnmount, ref } from 'vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
withDefaults(
defineProps<{
label?: string
size?: 'small' | 'large'
variant?: 'default' | 'gradient'
fluid?: boolean
}>(),
{
size: 'large',
variant: 'default',
fluid: true
size: 'large'
}
)
const rootClass = computed(() => cn('font-bold', props.fluid && 'w-full'))
const emit = defineEmits<{
subscribed: []
}>()

View File

@@ -9,17 +9,6 @@
icon="pi pi-lock"
severity="primary"
size="small"
:style="{
background: 'var(--color-subscription-button-gradient)',
color: 'var(--color-white)'
}"
:pt="{
root: {
style: {
borderColor: 'transparent'
}
}
}"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"
/>

View File

@@ -3,11 +3,7 @@
<div class="flex h-full flex-col gap-6">
<div class="flex items-baseline gap-2">
<span class="text-2xl font-inter font-semibold leading-tight">
{{
isActiveSubscription
? $t('subscription.title')
: $t('subscription.titleUnsubscribed')
}}
{{ $t('subscription.title') }}
</span>
<CloudBadge
reverse-order
@@ -65,7 +61,6 @@
v-else
:label="$t('subscription.subscribeNow')"
size="small"
:fluid="false"
class="text-xs"
@subscribed="handleRefresh"
/>
@@ -247,22 +242,6 @@
}"
@click="handleLearnMoreClick"
/>
<Button
:label="$t('subscription.partnerNodesCredits')"
text
severity="secondary"
icon="pi pi-question-circle"
class="text-xs"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleOpenPartnerNodesInfo"
/>
<Button
:label="$t('subscription.messageSupport')"
text
@@ -338,13 +317,6 @@ const {
handleRefresh,
handleLearnMoreClick
} = useSubscriptionActions()
const handleOpenPartnerNodesInfo = () => {
window.open(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
'_blank'
)
}
</script>
<style scoped>

View File

@@ -3,8 +3,6 @@ import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
@@ -16,7 +14,6 @@ export function useSubscriptionActions() {
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchStatus, formattedRenewalDate } = useSubscription()
const isLoadingSupport = ref(false)
@@ -38,13 +35,6 @@ export function useSubscriptionActions() {
const handleMessageSupport = async () => {
try {
isLoadingSupport.value = true
if (isCloud) {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
}
await commandStore.execute('Comfy.ContactSupport')
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)

View File

@@ -1,11 +1,6 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
checkForCompletedTopup as checkTopupUtil,
clearTopupTracking as clearTopupUtil,
startTopupTracking as startTopupUtil
} from '@/platform/telemetry/topupTracker'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
@@ -20,9 +15,6 @@ import type {
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
@@ -34,9 +26,7 @@ import type {
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryMetadata,
TemplateLibraryClosedMetadata,
TemplateMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
@@ -159,10 +149,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName)
}
trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
@@ -177,48 +163,18 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
)
}
trackApiCreditTopupSucceeded(): void {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
// Credit top-up tracking methods (composition with utility functions)
startTopupTracking(): void {
startTopupUtil()
}
checkForCompletedTopup(events: any[] | undefined | null): boolean {
return checkTopupUtil(events)
}
clearTopupTracking(): void {
clearTopupUtil()
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
const executionContext = this.getExecutionContext()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names
workflow_name: executionContext.workflow_name ?? 'untitled'
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
trackRunTriggeredViaKeybinding(): void {
this.trackEvent(TelemetryEvents.RUN_TRIGGERED_KEYBINDING)
}
trackRunTriggeredViaMenu(): void {
this.trackEvent(TelemetryEvents.RUN_TRIGGERED_MENU)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
@@ -271,18 +227,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
@@ -303,22 +251,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata)
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
this.trackEvent(TelemetryEvents.EXECUTION_START, context)
@@ -339,50 +271,22 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
const activeWorkflow = workflowStore.activeWorkflow
// Calculate node metrics in a single traversal
type NodeMetrics = {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
const nodeMetrics = reduceAllNodes(
app.graph,
(metrics, node) => {
(acc, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (
canonicalName &&
!metrics.api_node_names.includes(canonicalName)
) {
metrics.api_node_names.push(canonicalName)
}
return {
custom_node_count: acc.custom_node_count + (isCustomNode ? 1 : 0),
api_node_count: acc.api_node_count + (isApiNode ? 1 : 0),
subgraph_count: acc.subgraph_count + (isSubgraph ? 1 : 0)
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
}
{ custom_node_count: 0, api_node_count: 0, subgraph_count: 0 }
)
if (activeWorkflow?.filename) {
@@ -408,21 +312,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
...nodeMetrics
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename,
...nodeCounts
...nodeMetrics
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
...nodeMetrics
}
}
}

View File

@@ -1,61 +0,0 @@
import { useTelemetry } from '@/platform/telemetry'
import type { AuditLog } from '@/services/customerEventsService'
const STORAGE_KEY = 'pending_topup_timestamp'
const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
/**
* Start tracking a credit top-up purchase.
* Call this before opening the Stripe checkout window.
*/
export function startTopupTracking(): void {
localStorage.setItem(STORAGE_KEY, Date.now().toString())
}
/**
* Check if a pending top-up has completed by looking for a credit_added event
* that occurred after the tracking started.
*
* @param events - Array of audit log events to check
* @returns true if a completed top-up was detected and telemetry was sent
*/
export function checkForCompletedTopup(
events: AuditLog[] | undefined | null
): boolean {
const timestampStr = localStorage.getItem(STORAGE_KEY)
if (!timestampStr) return false
const timestamp = parseInt(timestampStr, 10)
// Auto-cleanup if expired (older than 24 hours)
if (Date.now() - timestamp > MAX_AGE_MS) {
localStorage.removeItem(STORAGE_KEY)
return false
}
if (!events || events.length === 0) return false
// Find credit_added event that occurred after our timestamp
const completedTopup = events.find(
(e) =>
e.event_type === 'credit_added' &&
e.createdAt &&
new Date(e.createdAt).getTime() > timestamp
)
if (completedTopup) {
useTelemetry()?.trackApiCreditTopupSucceeded()
localStorage.removeItem(STORAGE_KEY)
return true
}
return false
}
/**
* Clear any pending top-up tracking.
* Useful for testing or manual cleanup.
*/
export function clearTopupTracking(): void {
localStorage.removeItem(STORAGE_KEY)
}

View File

@@ -42,11 +42,6 @@ export interface RunButtonProperties {
subscribe_to_run: boolean
workflow_type: 'template' | 'custom'
workflow_name: string
custom_node_count: number
total_node_count: number
subgraph_count: number
has_api_nodes: boolean
api_node_names: string[]
}
/**
@@ -66,9 +61,6 @@ export interface ExecutionContext {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
/**
@@ -114,22 +106,8 @@ export interface CreditTopupMetadata {
export interface WorkflowImportMetadata {
missing_node_count: number
missing_node_types: string[]
/**
* The source of the workflow open/import action
*/
open_source?: 'file_button' | 'file_drop' | 'template' | 'unknown'
}
/**
* Workflow open metadata
*/
/**
* Enumerated sources for workflow open/import actions.
*/
export type WorkflowOpenSource = NonNullable<
WorkflowImportMetadata['open_source']
>
/**
* Template library metadata
*/
@@ -137,14 +115,6 @@ export interface TemplateLibraryMetadata {
source: 'sidebar' | 'menu' | 'command'
}
/**
* Template library closed metadata
*/
export interface TemplateLibraryClosedMetadata {
template_selected: boolean
time_spent_seconds: number
}
/**
* Page visibility metadata
*/
@@ -192,48 +162,6 @@ export interface TemplateFilterMetadata {
total_count: number
}
/**
* Help center opened metadata
*/
export interface HelpCenterOpenedMetadata {
source: 'menu' | 'topbar' | 'sidebar'
}
/**
* Help resource clicked metadata
*/
export interface HelpResourceClickedMetadata {
resource_type:
| 'docs'
| 'discord'
| 'github'
| 'help_feedback'
| 'manager'
| 'release_notes'
is_external: boolean
source:
| 'menu'
| 'help_center'
| 'error_dialog'
| 'credits_panel'
| 'subscription'
}
/**
* Help center closed metadata
*/
export interface HelpCenterClosedMetadata {
time_spent_seconds: number
}
/**
* Workflow created metadata
*/
export interface WorkflowCreatedMetadata {
workflow_type: 'blank' | 'default'
previous_workflow_had_nodes: boolean
}
/**
* Core telemetry provider interface
*/
@@ -245,17 +173,8 @@ export interface TelemetryProvider {
// Subscription flow events
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
trackMonthlySubscriptionSucceeded(): void
trackAddApiCreditButtonClicked(): void
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
trackApiCreditTopupSucceeded(): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void
trackRunTriggeredViaKeybinding(): void
trackRunTriggeredViaMenu(): void
// Credit top-up tracking (composition with internal utilities)
startTopupTracking(): void
checkForCompletedTopup(events: any[] | undefined | null): boolean
clearTopupTracking(): void
// Survey flow events
trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
@@ -266,11 +185,9 @@ export interface TelemetryProvider {
// Template workflow events
trackTemplate(metadata: TemplateMetadata): void
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void
// Workflow management events
trackWorkflowImported(metadata: WorkflowImportMetadata): void
trackWorkflowOpened(metadata: WorkflowImportMetadata): void
// Page visibility events
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
@@ -285,14 +202,6 @@ export interface TelemetryProvider {
// Template filter tracking events
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
// Help center events
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void
// Workflow creation events
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
// Workflow execution events
trackWorkflowExecution(): void
trackExecutionError(metadata: ExecutionErrorMetadata): void
@@ -313,15 +222,11 @@ export const TelemetryEvents = {
// Subscription Flow
RUN_BUTTON_CLICKED: 'app:run_button_click',
RUN_TRIGGERED_KEYBINDING: 'app:run_triggered_keybinding',
RUN_TRIGGERED_MENU: 'app:run_triggered_menu',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded',
// Onboarding Survey
USER_SURVEY_OPENED: 'app:user_survey_opened',
@@ -335,11 +240,9 @@ export const TelemetryEvents = {
// Template Tracking
TEMPLATE_WORKFLOW_OPENED: 'app:template_workflow_opened',
TEMPLATE_LIBRARY_OPENED: 'app:template_library_opened',
TEMPLATE_LIBRARY_CLOSED: 'app:template_library_closed',
// Workflow Management
WORKFLOW_IMPORTED: 'app:workflow_imported',
WORKFLOW_OPENED: 'app:workflow_opened',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
@@ -354,14 +257,6 @@ export const TelemetryEvents = {
// Template Filter Analytics
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
// Help Center Analytics
HELP_CENTER_OPENED: 'app:help_center_opened',
HELP_RESOURCE_CLICKED: 'app:help_resource_clicked',
HELP_CENTER_CLOSED: 'app:help_center_closed',
// Workflow Creation
WORKFLOW_CREATED: 'app:workflow_created',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
EXECUTION_ERROR: 'execution_error',
@@ -385,13 +280,8 @@ export type TelemetryEventProperties =
| CreditTopupMetadata
| WorkflowImportMetadata
| TemplateLibraryMetadata
| TemplateLibraryClosedMetadata
| PageVisibilityMetadata
| TabCountMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| TemplateFilterMetadata
| HelpCenterOpenedMetadata
| HelpResourceClickedMetadata
| HelpCenterClosedMetadata
| WorkflowCreatedMetadata

View File

@@ -138,9 +138,7 @@ export function useTemplateWorkflows() {
}
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName, {
openSource: 'template'
})
await app.loadGraphData(json, true, true, workflowName)
return true
}
@@ -161,9 +159,7 @@ export function useTemplateWorkflows() {
}
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName, {
openSource: 'template'
})
await app.loadGraphData(json, true, true, workflowName)
return true
} catch (error) {

View File

@@ -47,7 +47,8 @@ export function useLayoutSync() {
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
) {
liteNode.setSize([layout.size.width, layout.size.height])
liteNode.size[0] = layout.size.width
liteNode.size[1] = layout.size.height
}
}

View File

@@ -33,12 +33,10 @@
"
:style="[
{
// Position in Litegraph coordinates, then scale down from Vue DOM size to visual size
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px) scale(${VUE_TO_LITEGRAPH_SCALE})`,
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--node-component-surface': nodeBodyBackgroundColor,
transformOrigin: 'top left'
'--node-component-surface': nodeBodyBackgroundColor
},
dragStyle
]"
@@ -287,25 +285,16 @@ const handleContextMenu = (event: MouseEvent) => {
}
}
// Coordinate system constants:
// - Layout store uses Litegraph coordinate system (smaller values)
// - Vue nodes need larger intrinsic size due to padding/spacing
// - We scale up DOM by 2, then scale down visually by 1/2 via CSS transform
// - This achieves correct visual size while allowing proper intrinsic sizing
const LITEGRAPH_TO_VUE_SCALE = 2
const VUE_TO_LITEGRAPH_SCALE = 1 / 2 // 0.5
onMounted(() => {
// Set initial DOM size from layout store (convert Litegraph coords to Vue DOM coords)
// Layout store contains Litegraph coordinates, we scale up for Vue's larger intrinsic size
// Set initial DOM size from layout store, but respect intrinsic content minimum
if (size.value && nodeContainerRef.value) {
nodeContainerRef.value.style.setProperty(
'--node-width',
`${size.value.width * LITEGRAPH_TO_VUE_SCALE}px`
`${size.value.width}px`
)
nodeContainerRef.value.style.setProperty(
'--node-height',
`${(size.value.height + LiteGraph.NODE_TITLE_HEIGHT) * LITEGRAPH_TO_VUE_SCALE}px`
`${size.value.height}px`
)
}
})
@@ -352,16 +341,9 @@ const { startResize } = useNodeResize(
(result, element) => {
if (isCollapsed.value) return
// Convert from visual/canvas coordinates to Vue DOM coordinates
// result.size is the visual size from getBoundingClientRect (after CSS transform)
// This already includes NODE_TITLE_HEIGHT in the visual measurement
// We just need to scale up to DOM size by inverting the transform scale
const domWidth = result.size.width * LITEGRAPH_TO_VUE_SCALE
const domHeight = result.size.height * LITEGRAPH_TO_VUE_SCALE
// Apply size directly to DOM element - ResizeObserver will pick this up
element.style.setProperty('--node-width', `${domWidth}px`)
element.style.setProperty('--node-height', `${domHeight}px`)
element.style.setProperty('--node-width', `${result.size.width}px`)
element.style.setProperty('--node-height', `${result.size.height}px`)
const currentPosition = position.value
const deltaX = Math.abs(result.position.x - currentPosition.x)

View File

@@ -86,44 +86,25 @@ const resizeObserver = new ResizeObserver((entries) => {
if (!elementType || !elementId) continue
// Use borderBoxSize to include borders in measurements
// This matches the visual size that will be scaled by CSS transform
// Fallback to getBoundingClientRect for older engines
let width: number
let height: number
if (entry.borderBoxSize) {
const borderBox = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: entry.borderBoxSize
width = borderBox.inlineSize
height = borderBox.blockSize
} else {
// Fallback: use getBoundingClientRect which gives us borderBox size
const rect = element.getBoundingClientRect()
width = rect.width
height = rect.height
}
// Use contentBoxSize when available; fall back to contentRect for older engines/tests
const contentBox = Array.isArray(entry.contentBoxSize)
? entry.contentBoxSize[0]
: {
inlineSize: entry.contentRect.width,
blockSize: entry.contentRect.height
}
const width = contentBox.inlineSize
const height = contentBox.blockSize
// Screen-space rect
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
const topLeftCanvas = { x: cx, y: cy }
// Convert Vue DOM coordinates (scaled by 2x) to Litegraph coordinates
// - Layout store uses Litegraph coordinate system as single source of truth
// - Vue nodes are rendered at 2x size in DOM, then scaled down 0.5 via CSS transform
// - We use borderBoxSize (includes border) to match the visual size after transform
// - This prevents drift: borderBox 200px → store 100px → next cycle borderBox 200px ✓
const VUE_TO_LITEGRAPH_SCALE = 0.5
const bounds: Bounds = {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width: Math.max(0, width * VUE_TO_LITEGRAPH_SCALE),
height: Math.max(
0,
height * VUE_TO_LITEGRAPH_SCALE - LiteGraph.NODE_TITLE_HEIGHT
)
width: Math.max(0, width),
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
}
let updates = updatesByType.get(elementType)

View File

@@ -0,0 +1,94 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { createBounds } from '@/lib/litegraph/src/measure'
import { useSettingStore } from '@/platform/settings/settingStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { NodeBoundsUpdate } from '@/renderer/core/layout/types'
import { app as comfyApp } from '@/scripts/app'
const SCALE_FACTOR = 1.75
export function ensureCorrectLayoutScale() {
const settingStore = useSettingStore()
const autoScaleLayoutSetting = settingStore.get(
'Comfy.VueNodes.AutoScaleLayout'
)
if (autoScaleLayoutSetting === false) {
return
}
const canvas = comfyApp.canvas
const graph = canvas?.graph
if (!graph || !graph.nodes) return
if (graph.extra?.vueNodesScaled === true) {
return
}
const vueNodesEnabled = settingStore.get('Comfy.VueNodes.Enabled')
if (!vueNodesEnabled) {
return
}
const lgBounds = createBounds(graph.nodes)
if (!lgBounds) return
const allVueNodes = layoutStore.getAllNodes().value
const originX = lgBounds[0]
const originY = lgBounds[1]
const lgNodesById = new Map(
graph.nodes.map((node) => [String(node.id), node])
)
const yjsMoveNodeUpdates: NodeBoundsUpdate[] = []
for (const vueNode of allVueNodes.values()) {
const lgNode = lgNodesById.get(String(vueNode.id))
if (!lgNode) continue
const lgBodyY = lgNode.pos[1] - LiteGraph.NODE_TITLE_HEIGHT
const relativeX = lgNode.pos[0] - originX
const relativeY = lgBodyY - originY
const newX = originX + relativeX * SCALE_FACTOR
const newY = originY + relativeY * SCALE_FACTOR
const newWidth = lgNode.width * SCALE_FACTOR
const newHeight = lgNode.height * SCALE_FACTOR
yjsMoveNodeUpdates.push({
nodeId: vueNode.id,
bounds: {
x: newX,
y: newY,
width: newWidth,
height: newHeight
}
})
}
layoutStore.batchUpdateNodeBounds(yjsMoveNodeUpdates)
graph.groups.forEach((group) => {
const groupBodyY = group.pos[1] - LiteGraph.NODE_TITLE_HEIGHT
const relativeX = group.pos[0] - originX
const relativeY = groupBodyY - originY
const newPosY =
originY + relativeY * SCALE_FACTOR + LiteGraph.NODE_TITLE_HEIGHT
group.pos = [originX + relativeX * SCALE_FACTOR, newPosY]
group.size = [group.size[0] * SCALE_FACTOR, group.size[1] * SCALE_FACTOR]
})
const originScreen = canvas.ds.convertOffsetToCanvas([originX, originY])
canvas.ds.changeScale(canvas.ds.scale / SCALE_FACTOR, originScreen)
if (!graph.extra) graph.extra = {}
graph.extra.vueNodesScaled = true
}

View File

@@ -3,7 +3,6 @@
*/
import type { Component } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import WidgetAudioUI from '../components/WidgetAudioUI.vue'
@@ -132,8 +131,7 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
aliases: ['AUDIOUI', 'AUDIO_UI'],
essential: false
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }]
]
]
const getComboWidgetAdditions = (): Map<string, Component> => {

View File

@@ -482,6 +482,7 @@ const zSettings = z.object({
'Comfy.MaskEditor.BrushAdjustmentSpeed': z.number(),
'Comfy.MaskEditor.UseDominantAxis': z.boolean(),
'Comfy.Load3D.ShowGrid': z.boolean(),
'Comfy.Load3D.ShowPreview': z.boolean(),
'Comfy.Load3D.BackgroundColor': z.string(),
'Comfy.Load3D.LightIntensity': z.number(),
'Comfy.Load3D.LightIntensityMaximum': z.number(),

View File

@@ -19,7 +19,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -49,7 +48,6 @@ import { getMp3Metadata } from '@/scripts/metadata/mp3'
import { getOggMetadata } from '@/scripts/metadata/ogg'
import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphService } from '@/services/subgraphService'
@@ -101,6 +99,7 @@ import { $el, ComfyUI } from './ui'
import { ComfyAppMenu } from './ui/menu/index'
import { clone } from './utils'
import { type ComfyWidgetConstructor } from './widgets'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -551,7 +550,7 @@ export class ComfyApp {
event.dataTransfer.files.length &&
event.dataTransfer.files[0].type !== 'image/bmp'
) {
await this.handleFile(event.dataTransfer.files[0], 'file_drop')
await this.handleFile(event.dataTransfer.files[0])
} else {
// Try loading the first URI in the transfer list
const validTypes = ['text/uri-list', 'text/x-moz-url']
@@ -562,10 +561,7 @@ export class ComfyApp {
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
if (uri) {
const blob = await (await fetch(uri)).blob()
await this.handleFile(
new File([blob], uri, { type: blob.type }),
'file_drop'
)
await this.handleFile(new File([blob], uri, { type: blob.type }))
}
}
}
@@ -703,12 +699,9 @@ export class ComfyApp {
'Payment Required: Please add credits to your account to use this node.'
)
) {
const { isActiveSubscription } = useSubscription()
if (isActiveSubscription.value) {
useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
}
useDialogService().showTopUpCreditsDialog({
isInsufficientCredits: true
})
} else {
useDialogService().showExecutionErrorDialog(detail)
}
@@ -1047,19 +1040,12 @@ export class ComfyApp {
clean: boolean = true,
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null,
options: {
showMissingNodesDialog?: boolean
showMissingModelsDialog?: boolean
checkForRerouteMigration?: boolean
openSource?: WorkflowOpenSource
} = {}
) {
const {
{
showMissingNodesDialog = true,
showMissingModelsDialog = true,
checkForRerouteMigration = false,
openSource
} = options
checkForRerouteMigration = false
} = {}
) {
useWorkflowService().beforeLoadNewGraph()
if (clean !== false) {
@@ -1126,8 +1112,6 @@ export class ComfyApp {
if (n.type == 'ConditioningAverage ') n.type = 'ConditioningAverage' //typo fix
if (n.type == 'SDV_img2vid_Conditioning')
n.type = 'SVD_img2vid_Conditioning' //typo fix
if (n.type == 'Load3DAnimation') n.type = 'Load3D' // Animation node merged into Load3D
if (n.type == 'Preview3DAnimation') n.type = 'Preview3D' // Animation node merged into Load3D
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
@@ -1200,6 +1184,8 @@ export class ComfyApp {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
ensureCorrectLayoutScale()
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
@@ -1287,15 +1273,13 @@ export class ComfyApp {
missingNodeTypes
)
const telemetryPayload = {
// Track workflow import with missing node information
useTelemetry()?.trackWorkflowImported({
missing_node_count: missingNodeTypes.length,
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
),
open_source: openSource ?? 'unknown'
}
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
useTelemetry()?.trackWorkflowImported(telemetryPayload)
)
})
await useWorkflowService().afterLoadNewGraph(
workflow,
this.graph.serialize() as unknown as ComfyWorkflowJSON
@@ -1413,7 +1397,7 @@ export class ComfyApp {
* Loads workflow data from the specified file
* @param {File} file
*/
async handleFile(file: File, openSource?: WorkflowOpenSource) {
async handleFile(file: File) {
const removeExt = (f: string) => {
if (!f) return f
const p = f.lastIndexOf('.')
@@ -1428,8 +1412,7 @@ export class ComfyApp {
JSON.parse(pngInfo.workflow),
true,
true,
fileName,
{ openSource }
fileName
)
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
@@ -1449,9 +1432,7 @@ export class ComfyApp {
const { workflow, prompt } = await getAvifMetadata(file)
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
openSource
})
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
@@ -1464,9 +1445,7 @@ export class ComfyApp {
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
openSource
})
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
@@ -1475,7 +1454,7 @@ export class ComfyApp {
} else if (file.type === 'audio/mpeg') {
const { workflow, prompt } = await getMp3Metadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName, { openSource })
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
@@ -1484,7 +1463,7 @@ export class ComfyApp {
} else if (file.type === 'audio/ogg') {
const { workflow, prompt } = await getOggMetadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName, { openSource })
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
@@ -1496,9 +1475,7 @@ export class ComfyApp {
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
openSource
})
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
@@ -1507,9 +1484,7 @@ export class ComfyApp {
} else if (file.type === 'video/webm') {
const webmInfo = await getFromWebmFile(file)
if (webmInfo.workflow) {
this.loadGraphData(webmInfo.workflow, true, true, fileName, {
openSource
})
this.loadGraphData(webmInfo.workflow, true, true, fileName)
} else if (webmInfo.prompt) {
this.loadApiJson(webmInfo.prompt, fileName)
} else {
@@ -1525,18 +1500,14 @@ export class ComfyApp {
) {
const mp4Info = await getFromIsobmffFile(file)
if (mp4Info.workflow) {
this.loadGraphData(mp4Info.workflow, true, true, fileName, {
openSource
})
this.loadGraphData(mp4Info.workflow, true, true, fileName)
} else if (mp4Info.prompt) {
this.loadApiJson(mp4Info.prompt, fileName)
}
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
const svgInfo = await getSvgMetadata(file)
if (svgInfo.workflow) {
this.loadGraphData(svgInfo.workflow, true, true, fileName, {
openSource
})
this.loadGraphData(svgInfo.workflow, true, true, fileName)
} else if (svgInfo.prompt) {
this.loadApiJson(svgInfo.prompt, fileName)
} else {
@@ -1548,9 +1519,7 @@ export class ComfyApp {
) {
const gltfInfo = await getGltfBinaryMetadata(file)
if (gltfInfo.workflow) {
this.loadGraphData(gltfInfo.workflow, true, true, fileName, {
openSource
})
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
} else if (gltfInfo.prompt) {
this.loadApiJson(gltfInfo.prompt, fileName)
} else {
@@ -1573,8 +1542,7 @@ export class ComfyApp {
JSON.parse(readerResult),
true,
true,
fileName,
{ openSource }
fileName
)
}
}
@@ -1592,8 +1560,7 @@ export class ComfyApp {
JSON.parse(info.workflow),
true,
true,
fileName,
{ openSource }
fileName
)
// @ts-expect-error
} else if (info.prompt) {

View File

@@ -2,8 +2,6 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
import { type StatusWsMessageStatus, type TaskItem } from '@/schemas/apiSchema'
import { useDialogService } from '@/services/dialogService'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useLitegraphService } from '@/services/litegraphService'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -394,7 +392,7 @@ export class ComfyUI {
parent: document.body,
onchange: async () => {
// @ts-expect-error fixme ts strict error
await app.handleFile(fileInput.files[0], 'file_button')
await app.handleFile(fileInput.files[0])
fileInput.value = ''
}
})
@@ -472,12 +470,7 @@ export class ComfyUI {
$el('button.comfy-queue-btn', {
id: 'queue-button',
textContent: 'Queue Prompt',
onclick: () => {
if (isCloud) {
useTelemetry()?.trackRunTriggeredViaMenu()
}
app.queuePrompt(0, this.batchCount)
}
onclick: () => app.queuePrompt(0, this.batchCount)
}),
$el('div', {}, [
$el('label', { innerHTML: 'Extra options' }, [
@@ -579,12 +572,7 @@ export class ComfyUI {
$el('button', {
id: 'queue-front-button',
textContent: 'Queue Front',
onclick: () => {
if (isCloud) {
useTelemetry()?.trackRunTriggeredViaMenu()
}
app.queuePrompt(-1, this.batchCount)
}
onclick: () => app.queuePrompt(-1, this.batchCount)
}),
$el('button', {
$: (b) => (this.queue.button = b as HTMLButtonElement),

View File

@@ -179,7 +179,7 @@ export const useCustomerEventsService = () => {
return null
}
const result = await executeRequest<CustomerEventsResponse>(
return executeRequest<CustomerEventsResponse>(
() =>
customerApiClient.get('/customers/events', {
params: { page, limit },
@@ -187,8 +187,6 @@ export const useCustomerEventsService = () => {
}),
{ errorContext, routeSpecificErrors }
)
return result
}
return {

View File

@@ -14,7 +14,6 @@ import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { useDialogStore } from '@/stores/dialogStore'
@@ -342,9 +341,6 @@ export const useDialogService = () => {
function showTopUpCreditsDialog(options?: {
isInsufficientCredits?: boolean
}) {
const { isActiveSubscription } = useSubscription()
if (!isActiveSubscription.value) return
return dialogStore.showDialog({
key: 'top-up-credits',
component: TopUpCreditsDialogContent,

View File

@@ -1,7 +1,5 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -66,14 +64,6 @@ export const useKeybindingService = () => {
// Prevent default browser behavior first, then execute the command
event.preventDefault()
if (
isCloud &&
(keybinding.commandId === 'Comfy.QueuePrompt' ||
keybinding.commandId === 'Comfy.QueuePromptFront' ||
keybinding.commandId === 'Comfy.QueueSelectedOutputNodes')
) {
useTelemetry()?.trackRunTriggeredViaKeybinding()
}
await commandStore.execute(keybinding.commandId)
return
}

View File

@@ -1,15 +1,20 @@
import { toRaw } from 'vue'
import { nodeToLoad3dMap } from '@/composables/useLoad3d'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
type Load3dReadyCallback = (load3d: Load3d | Load3dAnimation) => void
const viewerInstances = new Map<NodeId, any>()
export class Load3dService {
private static instance: Load3dService
private nodeToLoad3dMap = new Map<LGraphNode, Load3d | Load3dAnimation>()
private pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
private constructor() {}
@@ -20,14 +25,84 @@ export class Load3dService {
return Load3dService.instance
}
getLoad3d(node: LGraphNode): Load3d | null {
registerLoad3d(
node: LGraphNode,
container: HTMLElement,
inputSpec: CustomInputSpec
) {
const rawNode = toRaw(node)
return nodeToLoad3dMap.get(rawNode) || null
if (this.nodeToLoad3dMap.has(rawNode)) {
this.removeLoad3d(rawNode)
}
const type = inputSpec.type
const isAnimation = type.includes('Animation')
const Load3dClass = isAnimation ? Load3dAnimation : Load3d
const instance = new Load3dClass(container, {
node: rawNode,
inputSpec: inputSpec
})
rawNode.onMouseEnter = function () {
instance.refreshViewport()
instance.updateStatusMouseOnNode(true)
}
rawNode.onMouseLeave = function () {
instance.updateStatusMouseOnNode(false)
}
rawNode.onResize = function () {
instance.handleResize()
}
rawNode.onDrawBackground = function () {
instance.renderer.domElement.hidden = this.flags.collapsed ?? false
}
this.nodeToLoad3dMap.set(rawNode, instance)
const callbacks = this.pendingCallbacks.get(rawNode)
if (callbacks) {
callbacks.forEach((callback) => callback(instance))
this.pendingCallbacks.delete(rawNode)
}
return instance
}
getNodeByLoad3d(load3d: Load3d): LGraphNode | null {
for (const [node, instance] of nodeToLoad3dMap) {
getLoad3d(node: LGraphNode): Load3d | Load3dAnimation | null {
const rawNode = toRaw(node)
return this.nodeToLoad3dMap.get(rawNode) || null
}
waitForLoad3d(node: LGraphNode, callback: Load3dReadyCallback): void {
const rawNode = toRaw(node)
const existingInstance = this.nodeToLoad3dMap.get(rawNode)
if (existingInstance) {
callback(existingInstance)
return
}
if (!this.pendingCallbacks.has(rawNode)) {
this.pendingCallbacks.set(rawNode, [])
}
this.pendingCallbacks.get(rawNode)!.push(callback)
}
getNodeByLoad3d(load3d: Load3d | Load3dAnimation): LGraphNode | null {
for (const [node, instance] of this.nodeToLoad3dMap) {
if (instance === load3d) {
return node
}
@@ -38,19 +113,22 @@ export class Load3dService {
removeLoad3d(node: LGraphNode) {
const rawNode = toRaw(node)
const instance = nodeToLoad3dMap.get(rawNode)
const instance = this.nodeToLoad3dMap.get(rawNode)
if (instance) {
instance.remove()
nodeToLoad3dMap.delete(rawNode)
this.nodeToLoad3dMap.delete(rawNode)
}
this.pendingCallbacks.delete(rawNode)
}
clear() {
for (const [node] of nodeToLoad3dMap) {
for (const [node] of this.nodeToLoad3dMap) {
this.removeLoad3d(node)
}
this.pendingCallbacks.clear()
}
getOrCreateViewer(node: LGraphNode) {
@@ -71,7 +149,7 @@ export class Load3dService {
viewerInstances.delete(node.id)
}
async copyLoad3dState(source: Load3d, target: Load3d) {
async copyLoad3dState(source: Load3d, target: Load3d | Load3dAnimation) {
const sourceModel = source.modelManager.currentModel
if (sourceModel) {
@@ -110,13 +188,12 @@ export class Load3dService {
.getCurrentBackgroundInfo()
if (sourceBackgroundInfo.type === 'image') {
const sourceNode = this.getNodeByLoad3d(source)
const sceneConfig = sourceNode?.properties?.['Scene Config'] as any
const backgroundPath = sceneConfig?.backgroundImage
const backgroundPath = sourceNode?.properties?.[
'Background Image'
] as string
if (backgroundPath) {
await target.setBackgroundImage(backgroundPath)
}
} else {
await target.setBackgroundImage('')
}
target.setLightIntensity(
@@ -126,6 +203,11 @@ export class Load3dService {
if (sourceCameraType === 'perspective') {
target.setFOV(source.getCameraManager().perspectiveCamera.fov)
}
const sourceNode = this.getNodeByLoad3d(source)
if (sourceNode?.properties?.['Edge Threshold']) {
target.setEdgeThreshold(sourceNode.properties['Edge Threshold'] as number)
}
}
handleViewportRefresh(load3d: Load3d | null) {
@@ -148,11 +230,6 @@ export class Load3dService {
if (viewer.needApplyChanges.value) {
await viewer.applyChanges()
// Sync configuration back to the node's UI
if ((node as any).syncLoad3dConfig) {
;(node as any).syncLoad3dConfig()
}
}
useLoad3dService().removeViewer(node)

View File

@@ -3,8 +3,6 @@ import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyExtension } from '@/types/comfy'
import { useCommandStore } from './commandStore'
@@ -64,17 +62,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
.map(
(command) =>
({
command: () => {
if (
isCloud &&
(command.id === 'Comfy.QueuePrompt' ||
command.id === 'Comfy.QueuePromptFront' ||
command.id === 'Comfy.QueueSelectedOutputNodes')
) {
useTelemetry()?.trackRunTriggeredViaMenu()
}
return commandStore.execute(command.id)
},
command: () => commandStore.execute(command.id),
label: command.menubarLabel,
icon: command.icon,
tooltip: command.tooltip,

View File

@@ -1,880 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn(),
uploadFile: vi.fn()
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
describe('useLoad3d', () => {
let mockLoad3d: any
let mockNode: any
let mockToastStore: any
beforeEach(() => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
mockNode = {
properties: {
'Scene Config': {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: ''
},
'Model Config': {
upDirection: 'original',
materialMode: 'original'
},
'Camera Config': {
cameraType: 'perspective',
fov: 75,
state: null
},
'Light Config': {
intensity: 5
},
'Resource Folder': ''
},
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
graph: {
setDirtyCanvas: vi.fn()
},
flags: {},
onMouseEnter: null,
onMouseLeave: null,
onResize: null,
onDrawBackground: null
}
mockLoad3d = {
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
refreshViewport: vi.fn(),
updateStatusMouseOnNode: vi.fn(),
updateStatusMouseOnScene: vi.fn(),
handleResize: vi.fn(),
toggleAnimation: vi.fn(),
setAnimationSpeed: vi.fn(),
updateSelectedAnimation: vi.fn(),
startRecording: vi.fn().mockResolvedValue(undefined),
stopRecording: vi.fn(),
getRecordingDuration: vi.fn().mockReturnValue(10),
exportRecording: vi.fn(),
clearRecording: vi.fn(),
exportModel: vi.fn().mockResolvedValue(undefined),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
renderer: {
domElement: {
hidden: false
}
}
}
vi.mocked(Load3d).mockImplementation(() => mockLoad3d)
mockToastStore = {
addAlert: vi.fn()
}
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const composable = useLoad3d(mockNode)
expect(composable.sceneConfig.value).toEqual({
showGrid: true,
backgroundColor: '#000000',
backgroundImage: ''
})
expect(composable.modelConfig.value).toEqual({
upDirection: 'original',
materialMode: 'original'
})
expect(composable.cameraConfig.value).toEqual({
cameraType: 'perspective',
fov: 75
})
expect(composable.lightConfig.value).toEqual({
intensity: 5
})
expect(composable.isRecording.value).toBe(false)
expect(composable.hasRecording.value).toBe(false)
expect(composable.loading.value).toBe(false)
})
it('should initialize Load3d with container and node', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(Load3d).toHaveBeenCalledWith(containerRef, {
node: mockNode
})
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
})
it('should restore configurations from node', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true)
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000')
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original')
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75)
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
})
it('should set up node event handlers', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockNode.onMouseEnter).toBeDefined()
expect(mockNode.onMouseLeave).toBeDefined()
expect(mockNode.onResize).toBeDefined()
expect(mockNode.onDrawBackground).toBeDefined()
// Test the handlers
mockNode.onMouseEnter()
expect(mockLoad3d.refreshViewport).toHaveBeenCalled()
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(true)
mockNode.onMouseLeave()
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(false)
mockNode.onResize()
expect(mockLoad3d.handleResize).toHaveBeenCalled()
})
it('should handle collapsed state', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.flags.collapsed = true
mockNode.onDrawBackground()
expect(mockLoad3d.renderer.domElement.hidden).toBe(true)
})
it('should load model if model_file widget exists', async () => {
mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'subfolder',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/test.glb'
)
})
it('should restore camera state after loading model', async () => {
mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
mockNode.properties['Camera Config'].state = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
}
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'subfolder',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
})
})
it('should set preview mode when no width/height widgets', async () => {
mockNode.widgets = []
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.isPreview.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(Load3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToInitializeLoad3d'
)
})
it('should handle missing container or node', async () => {
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(null as any)
expect(Load3d).not.toHaveBeenCalled()
})
it('should accept ref as parameter', () => {
const nodeRef = ref(mockNode)
const composable = useLoad3d(nodeRef)
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
})
})
describe('waitForLoad3d', () => {
it('should execute callback immediately if Load3d exists', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).toHaveBeenCalledWith(mockLoad3d)
})
it('should queue callback if Load3d does not exist', () => {
const composable = useLoad3d(mockNode)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).not.toHaveBeenCalled()
})
it('should execute queued callbacks after initialization', async () => {
const composable = useLoad3d(mockNode)
const callback1 = vi.fn()
const callback2 = vi.fn()
composable.waitForLoad3d(callback1)
composable.waitForLoad3d(callback2)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(callback1).toHaveBeenCalledWith(mockLoad3d)
expect(callback2).toHaveBeenCalledWith(mockLoad3d)
})
})
describe('configuration watchers', () => {
it('should update scene config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockLoad3d.toggleGrid.mockClear()
mockLoad3d.setBackgroundColor.mockClear()
mockLoad3d.setBackgroundImage.mockClear()
composable.sceneConfig.value = {
showGrid: false,
backgroundColor: '#ffffff',
backgroundImage: 'test.jpg'
}
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ffffff')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('test.jpg')
expect(mockNode.properties['Scene Config']).toEqual({
showGrid: false,
backgroundColor: '#ffffff',
backgroundImage: 'test.jpg'
})
})
it('should update model config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.modelConfig.value.upDirection = '+y'
composable.modelConfig.value.materialMode = 'wireframe'
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(mockNode.properties['Model Config']).toEqual({
upDirection: '+y',
materialMode: 'wireframe'
})
})
it('should update camera config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null
})
})
it('should update light config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.lightConfig.value.intensity = 10
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
expect(mockNode.properties['Light Config']).toEqual({
intensity: 10
})
})
})
describe('animation controls', () => {
it('should toggle animation playback', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.playing.value = true
await nextTick()
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
composable.playing.value = false
await nextTick()
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(false)
})
it('should update animation speed', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.selectedSpeed.value = 2
await nextTick()
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
})
it('should update selected animation', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.selectedAnimation.value = 1
await nextTick()
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(1)
})
})
describe('recording controls', () => {
it('should start recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleStartRecording()
expect(mockLoad3d.startRecording).toHaveBeenCalled()
expect(composable.isRecording.value).toBe(true)
})
it('should stop recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleStopRecording()
expect(mockLoad3d.stopRecording).toHaveBeenCalled()
expect(composable.isRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(10)
expect(composable.hasRecording.value).toBe(true)
})
it('should export recording with timestamp', async () => {
const dateSpy = vi
.spyOn(Date.prototype, 'toISOString')
.mockReturnValue('2024-01-01T12:00:00.000Z')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleExportRecording()
expect(mockLoad3d.exportRecording).toHaveBeenCalledWith(
'2024-01-01T12-00-00-000Z-scene-recording.mp4'
)
dateSpy.mockRestore()
})
it('should clear recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.hasRecording.value = true
composable.recordingDuration.value = 10
composable.handleClearRecording()
expect(mockLoad3d.clearRecording).toHaveBeenCalled()
expect(composable.hasRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(0)
})
})
describe('background image handling', () => {
it('should upload and set background image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(composable.sceneConfig.value.backgroundImage).toBe(
'uploaded-image.jpg'
)
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith(
'uploaded-image.jpg'
)
})
it('should use resource folder for upload', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
})
it('should clear background image when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundImage = 'existing.jpg'
await composable.handleBackgroundImageUpdate(null)
expect(composable.sceneConfig.value.backgroundImage).toBe('')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('')
})
})
describe('model export', () => {
it('should export model successfully', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleExportModel('glb')
expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb')
})
it('should show alert when no Load3d instance', async () => {
const composable = useLoad3d(mockNode)
await composable.handleExportModel('glb')
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dSceneToExport'
)
})
it('should handle export errors', async () => {
mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed'))
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleExportModel('glb')
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToExportModel'
)
})
})
describe('mouse interactions', () => {
it('should handle mouse enter on scene', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleMouseEnter()
expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(true)
})
it('should handle mouse leave on scene', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleMouseLeave()
expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(false)
})
})
describe('event handling', () => {
it('should add event listeners on initialization', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const expectedEvents = [
'materialModeChange',
'backgroundColorChange',
'lightIntensityChange',
'fovChange',
'cameraTypeChange',
'showGridChange',
'upDirectionChange',
'backgroundImageChange',
'backgroundImageLoadingStart',
'backgroundImageLoadingEnd',
'modelLoadingStart',
'modelLoadingEnd',
'exportLoadingStart',
'exportLoadingEnd',
'recordingStatusChange',
'animationListChange'
]
expectedEvents.forEach((event) => {
expect(mockLoad3d.addEventListener).toHaveBeenCalledWith(
event,
expect.any(Function)
)
})
})
it('should handle materialModeChange event', async () => {
let materialModeHandler: any
mockLoad3d.addEventListener.mockImplementation(
(event: string, handler: any) => {
if (event === 'materialModeChange') {
materialModeHandler = handler
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
materialModeHandler('wireframe')
expect(composable.modelConfig.value.materialMode).toBe('wireframe')
})
it('should handle loading events', async () => {
let modelLoadingStartHandler: any
let modelLoadingEndHandler: any
mockLoad3d.addEventListener.mockImplementation(
(event: string, handler: any) => {
if (event === 'modelLoadingStart') {
modelLoadingStartHandler = handler
} else if (event === 'modelLoadingEnd') {
modelLoadingEndHandler = handler
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
modelLoadingStartHandler()
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe('load3d.loadingModel')
modelLoadingEndHandler()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should handle recordingStatusChange event', async () => {
let recordingStatusHandler: any
mockLoad3d.addEventListener.mockImplementation(
(event: string, handler: any) => {
if (event === 'recordingStatusChange') {
recordingStatusHandler = handler
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
recordingStatusHandler(false)
expect(composable.isRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(10)
expect(composable.hasRecording.value).toBe(true)
})
})
describe('cleanup', () => {
it('should remove event listeners and clean up resources', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.cleanup()
expect(mockLoad3d.removeEventListener).toHaveBeenCalled()
expect(mockLoad3d.remove).toHaveBeenCalled()
expect(nodeToLoad3dMap.has(mockNode)).toBe(false)
})
it('should handle cleanup when not initialized', () => {
const composable = useLoad3d(mockNode)
expect(() => composable.cleanup()).not.toThrow()
})
})
describe('getModelUrl', () => {
it('should handle http URLs directly', async () => {
mockNode.widgets.push({
name: 'model_file',
value: 'http://example.com/model.glb'
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://example.com/model.glb'
)
})
it('should construct URL for local files', async () => {
mockNode.widgets.push({ name: 'model_file', value: 'models/test.glb' })
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'models',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/models/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/models/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb')
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'models',
'test.glb',
'input'
)
expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb')
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/models/test.glb'
)
})
it('should use output type for preview mode', async () => {
mockNode.widgets = [{ name: 'model_file', value: 'test.glb' }] // No width/height widgets
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'',
'test.glb',
'output'
)
})
})
describe('edge cases', () => {
it('should handle null node ref', () => {
const nodeRef = ref(null)
const composable = useLoad3d(nodeRef)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).not.toHaveBeenCalled()
})
it('should handle missing configurations', async () => {
delete mockNode.properties['Scene Config']
delete mockNode.properties['Model Config']
delete mockNode.properties['Camera Config']
delete mockNode.properties['Light Config']
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
// Should not throw and should use defaults
expect(Load3d).toHaveBeenCalled()
})
it('should handle background image with existing config', async () => {
mockNode.properties['Scene Config'].backgroundImage = 'existing.jpg'
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
})
})
})

View File

@@ -1,267 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useToastStore } from '@/platform/updates/common/toastStore'
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
function createMockDragEvent(
type: string,
options: { hasFiles?: boolean; files?: File[] } = {}
): DragEvent {
const files = options.files || []
const types = options.hasFiles ? ['Files'] : []
const dataTransfer = {
types,
files,
dropEffect: 'none' as DataTransfer['dropEffect']
}
const event = {
type,
dataTransfer
} as unknown as DragEvent
return event
}
describe('useLoad3dDrag', () => {
let mockToastStore: any
let mockOnModelDrop: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockToastStore = {
addAlert: vi.fn()
}
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
mockOnModelDrop = vi.fn()
})
it('should initialize with default state', () => {
const { isDragging, dragMessage } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
expect(isDragging.value).toBe(false)
expect(dragMessage.value).toBe('')
})
describe('handleDragOver', () => {
it('should set isDragging to true when files are being dragged', () => {
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const event = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(event)
expect(isDragging.value).toBe(true)
expect(event.dataTransfer!.dropEffect).toBe('copy')
})
it('should not set isDragging when disabled', () => {
const disabled = ref(true)
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop,
disabled
})
const event = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(event)
expect(isDragging.value).toBe(false)
})
it('should not set isDragging when no files are being dragged', () => {
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const event = createMockDragEvent('dragover', { hasFiles: false })
handleDragOver(event)
expect(isDragging.value).toBe(false)
})
})
describe('handleDragLeave', () => {
it('should reset isDragging to false', () => {
const { isDragging, handleDragLeave, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
// First set isDragging to true
const dragOverEvent = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(dragOverEvent)
expect(isDragging.value).toBe(true)
// Then test dragleave
handleDragLeave()
expect(isDragging.value).toBe(false)
})
})
describe('handleDrop', () => {
it('should call onModelDrop with valid model file', async () => {
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
const event = createMockDragEvent('drop', {
hasFiles: true,
files: [modelFile]
})
await handleDrop(event)
expect(mockOnModelDrop).toHaveBeenCalledWith(modelFile)
})
it('should show error toast for unsupported file types', async () => {
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const invalidFile = new File([], 'image.png', { type: 'image/png' })
const event = createMockDragEvent('drop', {
hasFiles: true,
files: [invalidFile]
})
await handleDrop(event)
expect(mockOnModelDrop).not.toHaveBeenCalled()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'load3d.unsupportedFileType'
)
})
it('should not call onModelDrop when disabled', async () => {
const disabled = ref(true)
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop,
disabled
})
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
const event = createMockDragEvent('drop', {
hasFiles: true,
files: [modelFile]
})
await handleDrop(event)
expect(mockOnModelDrop).not.toHaveBeenCalled()
})
it('should reset isDragging after drop', async () => {
const { isDragging, handleDrop, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
// Set isDragging to true
const dragOverEvent = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(dragOverEvent)
expect(isDragging.value).toBe(true)
// Drop the file
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
const dropEvent = createMockDragEvent('drop', {
hasFiles: true,
files: [modelFile]
})
await handleDrop(dropEvent)
expect(isDragging.value).toBe(false)
})
it('should support all valid 3D model extensions', async () => {
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const extensions = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
for (const ext of extensions) {
mockOnModelDrop.mockClear()
const modelFile = new File([], `model${ext}`)
const event = createMockDragEvent('drop', {
hasFiles: true,
files: [modelFile]
})
await handleDrop(event)
expect(mockOnModelDrop).toHaveBeenCalledWith(modelFile)
}
})
it('should handle empty file list', async () => {
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const event = createMockDragEvent('drop', {
hasFiles: true,
files: []
})
await handleDrop(event)
expect(mockOnModelDrop).not.toHaveBeenCalled()
expect(mockToastStore.addAlert).not.toHaveBeenCalled()
})
})
describe('disabled option', () => {
it('should work with reactive disabled ref', () => {
const disabled = ref(false)
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop,
disabled
})
const event = createMockDragEvent('dragover', { hasFiles: true })
// Should work when disabled is false
handleDragOver(event)
expect(isDragging.value).toBe(true)
// Reset
isDragging.value = false
// Should not work when disabled is true
disabled.value = true
handleDragOver(event)
expect(isDragging.value).toBe(false)
})
it('should work with plain boolean', () => {
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop,
disabled: false
})
const event = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(event)
expect(isDragging.value).toBe(true)
})
})
})

View File

@@ -41,28 +41,20 @@ describe('useLoad3dViewer', () => {
mockNode = {
properties: {
'Scene Config': {
backgroundColor: '#282828',
showGrid: true,
backgroundImage: ''
},
'Camera Config': {
cameraType: 'perspective',
fov: 75
},
'Light Config': {
intensity: 1
},
'Model Config': {
upDirection: 'original',
materialMode: 'original'
},
'Resource Folder': ''
'Background Color': '#282828',
'Show Grid': true,
'Camera Type': 'perspective',
FOV: 75,
'Light Intensity': 1,
'Camera Info': null,
'Background Image': '',
'Up Direction': 'original',
'Material Mode': 'original',
'Edge Threshold': 85
},
graph: {
setDirtyCanvas: vi.fn()
},
widgets: []
}
} as any
mockLoad3d = {
@@ -74,6 +66,7 @@ describe('useLoad3dViewer', () => {
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setEdgeThreshold: vi.fn(),
exportModel: vi.fn().mockResolvedValue(undefined),
handleResize: vi.fn(),
updateStatusMouseOnViewer: vi.fn(),
@@ -84,8 +77,7 @@ describe('useLoad3dViewer', () => {
cameraType: 'perspective'
}),
forceRender: vi.fn(),
remove: vi.fn(),
setTargetSize: vi.fn()
remove: vi.fn()
}
mockSourceLoad3d = {
@@ -150,6 +142,7 @@ describe('useLoad3dViewer', () => {
expect(viewer.hasBackgroundImage.value).toBe(false)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
expect(viewer.edgeThreshold.value).toBe(85)
})
it('should initialize viewer with source Load3d state', async () => {
@@ -176,6 +169,7 @@ describe('useLoad3dViewer', () => {
expect(viewer.fov.value).toBe(75)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
expect(viewer.edgeThreshold.value).toBe(85)
})
it('should handle background image during initialization', async () => {
@@ -183,7 +177,7 @@ describe('useLoad3dViewer', () => {
type: 'image',
value: ''
})
mockNode.properties['Scene Config'].backgroundImage = 'test-image.jpg'
mockNode.properties['Background Image'] = 'test-image.jpg'
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
@@ -308,6 +302,18 @@ describe('useLoad3dViewer', () => {
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
})
it('should update edge threshold when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.edgeThreshold.value = 90
await nextTick()
expect(mockLoad3d.setEdgeThreshold).toHaveBeenCalledWith(90)
})
it('should handle watcher errors gracefully', async () => {
mockLoad3d.setBackgroundColor.mockImplementationOnce(() => {
throw new Error('Color update failed')
@@ -405,20 +411,16 @@ describe('useLoad3dViewer', () => {
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
mockNode.properties['Scene Config'].backgroundColor = '#ff0000'
mockNode.properties['Scene Config'].showGrid = false
mockNode.properties['Background Color'] = '#ff0000'
mockNode.properties['Show Grid'] = false
viewer.restoreInitialState()
expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
'#282828'
)
expect(mockNode.properties['Scene Config'].showGrid).toBe(true)
expect(mockNode.properties['Camera Config'].cameraType).toBe(
'perspective'
)
expect(mockNode.properties['Camera Config'].fov).toBe(75)
expect(mockNode.properties['Light Config'].intensity).toBe(1)
expect(mockNode.properties['Background Color']).toBe('#282828')
expect(mockNode.properties['Show Grid']).toBe(true)
expect(mockNode.properties['Camera Type']).toBe('perspective')
expect(mockNode.properties['FOV']).toBe(75)
expect(mockNode.properties['Light Intensity']).toBe(1)
})
})
@@ -435,10 +437,8 @@ describe('useLoad3dViewer', () => {
const result = await viewer.applyChanges()
expect(result).toBe(true)
expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
'#ff0000'
)
expect(mockNode.properties['Scene Config'].showGrid).toBe(false)
expect(mockNode.properties['Background Color']).toBe('#ff0000')
expect(mockNode.properties['Show Grid']).toBe(false)
expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith(
mockLoad3d,
mockSourceLoad3d
@@ -582,10 +582,7 @@ describe('useLoad3dViewer', () => {
it('should handle orthographic camera', async () => {
mockSourceLoad3d.getCurrentCameraType.mockReturnValue('orthographic')
mockSourceLoad3d.cameraManager = {
perspectiveCamera: { fov: 75 }
}
delete mockNode.properties['Camera Config'].cameraType
mockSourceLoad3d.cameraManager = {} // No perspective camera
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')

View File

@@ -12,15 +12,11 @@ const mockT = vi.fn((key: string) => {
return key
})
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: mockT
})
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT
})
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({

View File

@@ -1,207 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuditLog } from '@/services/customerEventsService'
// Mock localStorage
const mockLocalStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
})
// Mock telemetry
const mockTelemetry = vi.hoisted(() => ({
trackApiCreditTopupSucceeded: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
describe('topupTracker', () => {
let topupTracker: typeof import('@/platform/telemetry/topupTracker')
beforeEach(async () => {
vi.clearAllMocks()
// Dynamically import to ensure fresh module state
topupTracker = await import('@/platform/telemetry/topupTracker')
})
describe('startTopupTracking', () => {
it('should save current timestamp to localStorage', () => {
const beforeTimestamp = Date.now()
topupTracker.startTopupTracking()
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'pending_topup_timestamp',
expect.any(String)
)
const savedTimestamp = parseInt(
mockLocalStorage.setItem.mock.calls[0][1],
10
)
expect(savedTimestamp).toBeGreaterThanOrEqual(beforeTimestamp)
expect(savedTimestamp).toBeLessThanOrEqual(Date.now())
})
})
describe('checkForCompletedTopup', () => {
it('should return false if no pending topup exists', () => {
mockLocalStorage.getItem.mockReturnValue(null)
const result = topupTracker.checkForCompletedTopup([])
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
})
it('should return false if events array is empty', () => {
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
const result = topupTracker.checkForCompletedTopup([])
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
})
it('should return false if events array is null', () => {
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
const result = topupTracker.checkForCompletedTopup(null)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
})
it('should auto-cleanup if timestamp is older than 24 hours', () => {
const oldTimestamp = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago
mockLocalStorage.getItem.mockReturnValue(oldTimestamp.toString())
const events: AuditLog[] = [
{
event_id: 'test-1',
event_type: 'credit_added',
createdAt: new Date().toISOString(),
params: { amount: 500 }
}
]
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
})
it('should detect completed topup and fire telemetry', () => {
const startTimestamp = Date.now() - 5 * 60 * 1000 // 5 minutes ago
mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
const events: AuditLog[] = [
{
event_id: 'test-1',
event_type: 'api_usage_completed',
createdAt: new Date(startTimestamp - 1000).toISOString(),
params: {}
},
{
event_id: 'test-2',
event_type: 'credit_added',
createdAt: new Date(startTimestamp + 1000).toISOString(),
params: { amount: 500 }
}
]
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(true)
expect(mockTelemetry.trackApiCreditTopupSucceeded).toHaveBeenCalledOnce()
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
})
it('should not detect topup if credit_added event is before tracking started', () => {
const startTimestamp = Date.now()
mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
const events: AuditLog[] = [
{
event_id: 'test-1',
event_type: 'credit_added',
createdAt: new Date(startTimestamp - 1000).toISOString(), // Before tracking
params: { amount: 500 }
}
]
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled()
})
it('should ignore events without createdAt timestamp', () => {
const startTimestamp = Date.now()
mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
const events: AuditLog[] = [
{
event_id: 'test-1',
event_type: 'credit_added',
createdAt: undefined,
params: { amount: 500 }
}
]
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
})
it('should only match credit_added events, not other event types', () => {
const startTimestamp = Date.now()
mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString())
const events: AuditLog[] = [
{
event_id: 'test-1',
event_type: 'api_usage_completed',
createdAt: new Date(startTimestamp + 1000).toISOString(),
params: {}
},
{
event_id: 'test-2',
event_type: 'account_created',
createdAt: new Date(startTimestamp + 2000).toISOString(),
params: {}
}
]
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
})
})
describe('clearTopupTracking', () => {
it('should remove pending topup from localStorage', () => {
topupTracker.clearTopupTracking()
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'
)
})
})
})