mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 13:59:28 +00:00
Compare commits
2 Commits
test/vue-n
...
v1.31.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e5c4745c | ||
|
|
663bc9c2c1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -92,6 +92,3 @@ storybook-static
|
||||
.github/instructions/nx.instructions.md
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal file
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal 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 />'
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
119
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
const safeLabel = label.replace(/</g, '<').replace(/>/g, '>')
|
||||
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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
336
src/components/load3d/Load3DAnimation.vue
Normal file
336
src/components/load3d/Load3DAnimation.vue
Normal 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>
|
||||
@@ -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>
|
||||
208
src/components/load3d/Load3DAnimationScene.vue
Normal file
208
src/components/load3d/Load3DAnimationScene.vue
Normal 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>
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
131
src/extensions/core/load3d/Load3dAnimation.ts
Normal file
131
src/extensions/core/load3d/Load3dAnimation.ts
Normal 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
|
||||
416
src/extensions/core/load3d/PreviewManager.ts
Normal file
416
src/extensions/core/load3d/PreviewManager.ts
Normal 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 {}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
}
|
||||
`
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: []
|
||||
}>()
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user