Merge remote-tracking branch 'origin/main' into fix/spacebar-panning-vue-nodes

This commit is contained in:
Johnpaul
2026-01-05 22:24:39 +01:00
50 changed files with 9626 additions and 6915 deletions

1
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1 @@
AMD and the AMD Arrow logo are trademarks of Advanced Micro Devices, Inc.

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/desktop-ui",
"version": "0.0.4",
"version": "0.0.6",
"type": "module",
"nx": {
"tags": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,84 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import type {
ElectronAPI,
TorchDeviceType
} from '@comfyorg/comfyui-electron-types'
import { ref } from 'vue'
import GpuPicker from './GpuPicker.vue'
type Platform = ReturnType<ElectronAPI['getPlatform']>
type ElectronAPIStub = Pick<ElectronAPI, 'getPlatform'>
type WindowWithElectron = Window & { electronAPI?: ElectronAPIStub }
const meta: Meta<typeof GpuPicker> = {
title: 'Desktop/Components/GpuPicker',
component: GpuPicker,
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
}
}
export default meta
type Story = StoryObj<typeof meta>
function createElectronDecorator(platform: Platform) {
function getPlatform() {
return platform
}
return function ElectronDecorator() {
const windowWithElectron = window as WindowWithElectron
windowWithElectron.electronAPI = { getPlatform }
return { template: '<story />' }
}
}
function renderWithDevice(device: TorchDeviceType | null) {
return function Render() {
return {
components: { GpuPicker },
setup() {
const selected = ref<TorchDeviceType | null>(device)
return { selected }
},
template: `
<div class="min-h-screen bg-neutral-950 p-8">
<GpuPicker v-model:device="selected" />
</div>
`
}
}
}
const windowsDecorator = createElectronDecorator('win32')
const macDecorator = createElectronDecorator('darwin')
export const WindowsNvidiaSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('nvidia')
}
export const WindowsAmdSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('amd')
}
export const WindowsCpuSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('cpu')
}
export const MacMpsSelected: Story = {
decorators: [macDecorator],
render: renderWithDevice('mps')
}

View File

@@ -11,29 +11,32 @@
<!-- Apple Metal / NVIDIA -->
<HardwareOption
v-if="platform === 'darwin'"
:image-path="'./assets/images/apple-mps-logo.png'"
image-path="./assets/images/apple-mps-logo.png"
placeholder-text="Apple Metal"
subtitle="Apple Metal"
:value="'mps'"
:selected="selected === 'mps'"
:recommended="true"
@click="pickGpu('mps')"
/>
<HardwareOption
v-else
:image-path="'./assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:value="'nvidia'"
:selected="selected === 'nvidia'"
:recommended="true"
@click="pickGpu('nvidia')"
/>
<template v-else>
<HardwareOption
image-path="./assets/images/nvidia-logo-square.jpg"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:selected="selected === 'nvidia'"
@click="pickGpu('nvidia')"
/>
<HardwareOption
image-path="./assets/images/amd-rocm-logo.png"
placeholder-text="AMD"
:subtitle="$t('install.gpuPicker.amdSubtitle')"
:selected="selected === 'amd'"
@click="pickGpu('amd')"
/>
</template>
<!-- CPU -->
<HardwareOption
placeholder-text="CPU"
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
:value="'cpu'"
:selected="selected === 'cpu'"
@click="pickGpu('cpu')"
/>
@@ -41,7 +44,6 @@
<HardwareOption
placeholder-text="Manual Install"
:subtitle="$t('install.gpuPicker.manualSubtitle')"
:value="'unsupported'"
:selected="selected === 'unsupported'"
@click="pickGpu('unsupported')"
/>
@@ -81,13 +83,15 @@ const selected = defineModel<TorchDeviceType | null>('device', {
const electron = electronAPI()
const platform = electron.getPlatform()
const showRecommendedBadge = computed(
() => selected.value === 'mps' || selected.value === 'nvidia'
const recommendedDevices: TorchDeviceType[] = ['mps', 'nvidia', 'amd']
const showRecommendedBadge = computed(() =>
selected.value ? recommendedDevices.includes(selected.value) : false
)
const descriptionKeys = {
mps: 'appleMetal',
nvidia: 'nvidia',
amd: 'amd',
cpu: 'cpu',
unsupported: 'manual'
} as const
@@ -97,7 +101,7 @@ const descriptionText = computed(() => {
return st(`install.gpuPicker.${key}Description`, '')
})
const pickGpu = (value: TorchDeviceType) => {
function pickGpu(value: TorchDeviceType) {
selected.value = value
}
</script>

View File

@@ -29,7 +29,6 @@ export const AppleMetalSelected: Story = {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: true
}
}
@@ -39,7 +38,6 @@ export const AppleMetalUnselected: Story = {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: false
}
}
@@ -48,7 +46,6 @@ export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
value: 'cpu',
selected: false
}
}
@@ -57,7 +54,6 @@ export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
value: 'unsupported',
selected: false
}
}
@@ -67,7 +63,6 @@ export const NvidiaSelected: Story = {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
value: 'nvidia',
selected: true
}
}

View File

@@ -36,17 +36,13 @@
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string
placeholderText: string
subtitle?: string
value: TorchDeviceType
selected?: boolean
recommended?: boolean
}
defineProps<Props>()

View File

@@ -104,8 +104,8 @@
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { isInChina } from '@comfyorg/shared-frontend-utils/networkUtil'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
@@ -155,7 +155,7 @@ const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI()
// Mirror configuration logic
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
@@ -170,6 +170,7 @@ const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'amd':
case 'cpu':
default:
return {

View File

@@ -63,7 +63,6 @@ const taskStore = useMaintenanceTaskStore()
defineProps<{
displayAsList: string
filter: MaintenanceFilter
isRefreshing: boolean
}>()
const executeTask = async (task: MaintenanceTask) => {

View File

@@ -143,6 +143,8 @@ const goToPreviousStep = () => {
const electron = electronAPI()
const router = useRouter()
const install = async () => {
if (!device.value) return
const options: InstallOptions = {
installPath: installPath.value,
autoUpdate: autoUpdate.value,
@@ -152,7 +154,6 @@ const install = async () => {
pythonMirror: pythonMirror.value,
pypiMirror: pypiMirror.value,
torchMirror: torchMirror.value,
// @ts-expect-error fixme ts strict error
device: device.value
}
electron.installComfyUI(options)
@@ -166,7 +167,11 @@ onMounted(async () => {
if (!electron) return
const detectedGpu = await electron.Config.getDetectedGpu()
if (detectedGpu === 'mps' || detectedGpu === 'nvidia') {
if (
detectedGpu === 'mps' ||
detectedGpu === 'nvidia' ||
detectedGpu === 'amd'
) {
device.value = detectedGpu
}

View File

@@ -74,7 +74,6 @@
class="border-neutral-700 border-solid border-x-0 border-y"
:filter
:display-as-list
:is-refreshing
/>
<!-- Actions -->

View File

@@ -19,6 +19,7 @@ test.describe('Graph', () => {
})
test('Validate workflow links', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.loadWorkflow('links/bad_link')
await expect(comfyPage.getVisibleToastCount()).resolves.toBe(2)
})

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.0",
"version": "1.37.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -42,6 +42,7 @@
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
@@ -186,6 +187,7 @@
"vue-i18n": "catalog:",
"vue-router": "catalog:",
"vuefire": "catalog:",
"wwobjloader2": "catalog:",
"yjs": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:"

46
pnpm-lock.yaml generated
View File

@@ -10,8 +10,8 @@ catalogs:
specifier: ^5.2.0
version: 5.2.0
'@comfyorg/comfyui-electron-types':
specifier: 0.5.5
version: 0.5.5
specifier: 0.6.2
version: 0.6.2
'@eslint/js':
specifier: ^9.39.1
version: 9.39.1
@@ -309,6 +309,9 @@ catalogs:
vuefire:
specifier: ^3.2.1
version: 3.2.1
wwobjloader2:
specifier: ^6.2.1
version: 6.2.1
yjs:
specifier: ^13.6.27
version: 13.6.27
@@ -337,7 +340,7 @@ importers:
version: 1.3.1
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.5.5
version: 0.6.2
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
@@ -497,6 +500,9 @@ importers:
vuefire:
specifier: 'catalog:'
version: 3.2.1(consola@3.4.2)(firebase@11.6.0)(vue@3.5.13(typescript@5.9.3))
wwobjloader2:
specifier: 'catalog:'
version: 6.2.1(three@0.170.0)
yjs:
specifier: 'catalog:'
version: 13.6.27
@@ -737,7 +743,7 @@ importers:
dependencies:
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.5.5
version: 0.6.2
'@comfyorg/shared-frontend-utils':
specifier: workspace:*
version: link:../../packages/shared-frontend-utils
@@ -1477,8 +1483,8 @@ packages:
'@cacheable/utils@2.3.2':
resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==}
'@comfyorg/comfyui-electron-types@0.5.5':
resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
'@comfyorg/comfyui-electron-types@0.6.2':
resolution: {integrity: sha512-r3By5Wbizq8jagUrhtcym79HYUTinsvoBnYkFFWbUmrURBWIaC0HduFVkRkI1PNdI76piW+JSOJJnw00YCVXeg==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -8225,6 +8231,19 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
wtd-core@3.0.0:
resolution: {integrity: sha512-LSPfAQ5ULSV5vPhipcjdQvV5xOx25QesYK23jwUOF99xOx6fuulk7CMQerERRwA4uoQooNmRd8AT6IPBwORlWQ==}
wtd-three-ext@3.0.0:
resolution: {integrity: sha512-PLZJipCAiinot8D1uB4A7+XHxPAYeZXDhczbbazK7pKdqpE77zMizQH4rSZsaNbzktgnIfpgK/ODqhJTdrUjUw==}
peerDependencies:
three: '>= 0.137.5 < 1'
wwobjloader2@6.2.1:
resolution: {integrity: sha512-/v/sfUX0PMQAI8souzCs6xsO9LR3RyL+ujnOiS/1pngUlakKyHYC5XMQvu77pTeWzY3rzNyt5Q/bg5O3RukA+g==}
peerDependencies:
three: '>= 0.137.5 < 1'
xdg-basedir@5.1.0:
resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==}
engines: {node: '>=12'}
@@ -9179,7 +9198,7 @@ snapshots:
hashery: 1.3.0
keyv: 5.5.5
'@comfyorg/comfyui-electron-types@0.5.5': {}
'@comfyorg/comfyui-electron-types@0.6.2': {}
'@csstools/color-helpers@5.1.0': {}
@@ -17125,6 +17144,19 @@ snapshots:
dependencies:
is-wsl: 3.1.0
wtd-core@3.0.0: {}
wtd-three-ext@3.0.0(three@0.170.0):
dependencies:
three: 0.170.0
wtd-core: 3.0.0
wwobjloader2@6.2.1(three@0.170.0):
dependencies:
three: 0.170.0
wtd-core: 3.0.0
wtd-three-ext: 3.0.0(three@0.170.0)
xdg-basedir@5.1.0: {}
xml-name-validator@4.0.0: {}

View File

@@ -4,7 +4,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
@@ -104,6 +104,7 @@ catalog:
vue-router: ^4.4.3
vue-tsc: ^3.2.1
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27
zod: ^3.23.8
zod-to-json-schema: ^3.24.1

View File

@@ -92,7 +92,8 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
@@ -106,8 +107,10 @@ const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const isQueueOverlayExpanded = ref(false)
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
@@ -133,7 +136,7 @@ onMounted(() => {
})
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const openCustomNodeManager = async () => {

View File

@@ -33,6 +33,9 @@
v-model:playing="playing"
v-model:selected-speed="selectedSpeed"
v-model:selected-animation="selectedAnimation"
v-model:animation-progress="animationProgress"
v-model:animation-duration="animationDuration"
@seek="handleSeek"
/>
</div>
<div
@@ -119,6 +122,8 @@ const {
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
animationDuration,
loading,
loadingMessage,
@@ -130,6 +135,7 @@ const {
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="container"
class="relative h-full w-full"
class="relative h-full w-full min-h-[200px]"
data-capture-wheel="true"
@pointerdown.stop
@pointermove.stop

View File

@@ -15,6 +15,16 @@
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
/>
<AnimationControls
v-if="viewer.animations.value && viewer.animations.value.length > 0"
v-model:animations="viewer.animations.value"
v-model:playing="viewer.playing.value"
v-model:selected-speed="viewer.selectedSpeed.value"
v-model:selected-animation="viewer.selectedAnimation.value"
v-model:animation-progress="viewer.animationProgress.value"
v-model:animation-duration="viewer.animationDuration.value"
@seek="viewer.handleSeek"
/>
<div
v-if="isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -85,6 +95,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'

View File

@@ -1,42 +1,64 @@
<template>
<div
v-if="animations && animations.length > 0"
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full items-center justify-center gap-2 pt-2"
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full flex-col items-center gap-2 pt-2"
>
<Button
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('g.playPause')"
@click="togglePlay"
>
<i
:class="['pi', playing ? 'pi-pause' : 'pi-play', 'text-lg text-white']"
<div class="flex items-center justify-center gap-2">
<Button
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('g.playPause')"
@click="togglePlay"
>
<i
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-lg text-white'
]"
/>
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
</div>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
<div class="flex w-full max-w-xs items-center gap-2 px-4">
<Slider
:model-value="[animationProgress]"
:min="0"
:max="100"
:step="0.1"
class="flex-1"
@update:model-value="handleSliderChange"
/>
<span class="min-w-16 text-xs text-white">
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
type Animation = { name: string; index: number }
@@ -44,6 +66,16 @@ const animations = defineModel<Animation[]>('animations')
const playing = defineModel<boolean>('playing')
const selectedSpeed = defineModel<number>('selectedSpeed')
const selectedAnimation = defineModel<number>('selectedAnimation')
const animationProgress = defineModel<number>('animationProgress', {
default: 0
})
const animationDuration = defineModel<number>('animationDuration', {
default: 0
})
const emit = defineEmits<{
seek: [progress: number]
}>()
const speedOptions = [
{ name: '0.1x', value: 0.1 },
@@ -53,7 +85,25 @@ const speedOptions = [
{ name: '2x', value: 2 }
]
const togglePlay = () => {
const currentTime = computed(() => {
if (!animationDuration.value) return 0
return (animationProgress.value / 100) * animationDuration.value
})
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = (seconds % 60).toFixed(1)
return mins > 0 ? `${mins}:${secs.padStart(4, '0')}` : `${secs}s`
}
function togglePlay() {
playing.value = !playing.value
}
function handleSliderChange(value: number[] | undefined) {
if (!value) return
const progress = value[0]
animationProgress.value = progress
emit('seek', progress)
}
</script>

View File

@@ -1,8 +1,6 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
<h3 class="text-center text-[15px] font-sans text-descrip-text mt-2.5">
{{ t('maskEditor.brushSettings') }}
</h3>
@@ -10,120 +8,211 @@
{{ t('maskEditor.resetToDefault') }}
</button>
<!-- Brush Shape -->
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.brushShape')
}}</span>
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.brushShape') }}
</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
>
<div
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-comfy-menu-bg"
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
:style="{
background:
class="maskEditor_sidePanelBrushShapeCircle hover:bg-comfy-menu-bg"
:class="
cn(
store.brushSettings.type === BrushShape.Arc
? 'var(--p-button-text-primary-color)'
: ''
}"
? 'bg-[var(--p-button-text-primary-color)] active'
: 'bg-transparent'
)
"
@click="setBrushShape(BrushShape.Arc)"
></div>
<div
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-comfy-menu-bg"
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
:style="{
background:
class="maskEditor_sidePanelBrushShapeSquare hover:bg-comfy-menu-bg"
:class="
cn(
store.brushSettings.type === BrushShape.Rect
? 'var(--p-button-text-primary-color)'
: ''
}"
? 'bg-[var(--p-button-text-primary-color)] active'
: 'bg-transparent'
)
"
@click="setBrushShape(BrushShape.Rect)"
></div>
</div>
</div>
<!-- Color -->
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.colorSelector')
}}</span>
<input type="color" :value="store.rgbColor" @input="onColorChange" />
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.colorSelector') }}
</span>
<input
ref="colorInputRef"
v-model="store.rgbColor"
type="color"
class="h-10 rounded-md cursor-pointer"
/>
</div>
<SliderControl
:label="t('maskEditor.thickness')"
:min="1"
:max="500"
:step="1"
:model-value="store.brushSettings.size"
@update:model-value="onThicknessChange"
/>
<!-- Thickness -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.thickness') }}
</span>
<input
v-model.number="brushSize"
type="number"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="1"
:max="250"
:step="1"
/>
</div>
<SliderControl
v-model="brushSize"
class="flex-1"
label=""
:min="1"
:max="250"
:step="1"
/>
</div>
<SliderControl
:label="t('maskEditor.opacity')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.brushSettings.opacity"
@update:model-value="onOpacityChange"
/>
<!-- Opacity -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.opacity') }}
</span>
<input
v-model.number="brushOpacity"
type="number"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="0"
:max="1"
:step="0.01"
/>
</div>
<SliderControl
v-model="brushOpacity"
class="flex-1"
label=""
:min="0"
:max="1"
:step="0.01"
/>
</div>
<SliderControl
:label="t('maskEditor.hardness')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.brushSettings.hardness"
@update:model-value="onHardnessChange"
/>
<!-- Hardness -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.hardness') }}
</span>
<input
v-model.number="brushHardness"
type="number"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="0"
:max="1"
:step="0.01"
/>
</div>
<SliderControl
v-model="brushHardness"
class="flex-1"
label=""
:min="0"
:max="1"
:step="0.01"
/>
</div>
<SliderControl
:label="$t('maskEditor.stepSize')"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.stepSize"
@update:model-value="onStepSizeChange"
/>
<!-- Step Size -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.stepSize') }}
</span>
<input
v-model.number="brushStepSize"
type="number"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="1"
:max="100"
:step="1"
/>
</div>
<SliderControl
v-model="brushStepSize"
class="flex-1"
label=""
:min="1"
:max="100"
:step="1"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { cn } from '@/utils/tailwindUtil'
import SliderControl from './controls/SliderControl.vue'
const store = useMaskEditorStore()
const textButtonClass =
'h-7.5 w-32 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
const colorInputRef = ref<HTMLInputElement>()
const textButtonClass =
'h-7.5 w-32 rounded-[10px] border border-p-form-field-border-color text-input-text font-sans transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
/* Computed properties that use store setters for validation */
const brushSize = computed({
get: () => store.brushSettings.size,
set: (value: number) => store.setBrushSize(value)
})
const brushOpacity = computed({
get: () => store.brushSettings.opacity,
set: (value: number) => store.setBrushOpacity(value)
})
const brushHardness = computed({
get: () => store.brushSettings.hardness,
set: (value: number) => store.setBrushHardness(value)
})
const brushStepSize = computed({
get: () => store.brushSettings.stepSize,
set: (value: number) => store.setBrushStepSize(value)
})
/* Brush shape */
const setBrushShape = (shape: BrushShape) => {
store.brushSettings.type = shape
}
const onColorChange = (event: Event) => {
store.rgbColor = (event.target as HTMLInputElement).value
}
const onThicknessChange = (value: number) => {
store.setBrushSize(value)
}
const onOpacityChange = (value: number) => {
store.setBrushOpacity(value)
}
const onHardnessChange = (value: number) => {
store.setBrushHardness(value)
}
const onStepSizeChange = (value: number) => {
store.setBrushStepSize(value)
}
/* Reset */
const resetToDefault = () => {
store.resetBrushToDefault()
}
onMounted(() => {
if (colorInputRef.value) {
store.colorInput = colorInputRef.value
}
})
onBeforeUnmount(() => {
store.colorInput = null
})
</script>

View File

@@ -202,6 +202,7 @@ onBeforeUnmount(() => {
}
store.canvasHistory.clearStates()
store.resetState()
dataStore.reset()
})

View File

@@ -34,6 +34,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
@@ -47,6 +48,7 @@ export interface SafeWidgetData {
borderStyle?: string
callback?: ((value: unknown) => void) | undefined
controlWidget?: SafeControlWidget
hasLayoutSize?: boolean
isDOMWidget?: boolean
label?: string
nodeType?: string
@@ -171,7 +173,12 @@ export function safeWidgetMapper(
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
widget.callback?.(value)
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
return {
@@ -181,6 +188,7 @@ export function safeWidgetMapper(
borderStyle,
callback,
controlWidget: getControlWidget(widget),
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget),
label: widget.label,
nodeType: getNodeType(node, widget),

View File

@@ -39,7 +39,11 @@ import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import {
useQueueSettingsStore,
useQueueStore,
useQueueUIStore
} from '@/stores/queueStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -423,6 +427,18 @@ export function useCoreCommands(): ComfyCommand[] {
},
active: () => useSettingStore().get('Comfy.Minimap.Visible')
},
{
id: 'Comfy.Queue.ToggleOverlay',
icon: 'pi pi-history',
label: () => t('queue.toggleJobHistory'),
menubarLabel: () => t('queue.jobHistory'),
versionAdded: '1.37.0',
category: 'view-controls' as const,
function: () => {
useQueueUIStore().toggleOverlay()
},
active: () => useQueueUIStore().isOverlayExpanded
},
{
id: 'Comfy.QueuePrompt',
icon: 'pi pi-play',

View File

@@ -60,6 +60,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const playing = ref(false)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const animationProgress = ref(0)
const animationDuration = ref(0)
const loading = ref(false)
const loadingMessage = ref('')
const isPreview = ref(false)
@@ -357,6 +359,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const handleSeek = (progress: number) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
load3d.setAnimationTime(time)
}
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
sceneConfig.value.backgroundImage = ''
@@ -514,6 +523,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
animationListChange: (newValue: AnimationItem[]) => {
animations.value = newValue
},
animationProgressChange: (data: {
progress: number
currentTime: number
duration: number
}) => {
animationProgress.value = data.progress
animationDuration.value = data.duration
},
cameraChanged: (cameraState: CameraState) => {
const rawNode = toRaw(nodeRef.value)
if (rawNode) {
@@ -573,6 +590,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
animationDuration,
loading,
loadingMessage,
@@ -585,6 +604,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,

View File

@@ -3,6 +3,7 @@ import { ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
BackgroundRenderModeType,
CameraState,
CameraType,
@@ -49,6 +50,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const isSplatModel = ref(false)
const isPlyModel = ref(false)
// Animation state
const animations = ref<AnimationItem[]>([])
const playing = ref(false)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const animationProgress = ref(0)
const animationDuration = ref(0)
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
@@ -174,6 +183,61 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
})
// Animation watches
watch(playing, (newValue) => {
if (load3d) {
load3d.toggleAnimation(newValue)
}
})
watch(selectedSpeed, (newValue) => {
if (load3d && newValue) {
load3d.setAnimationSpeed(newValue)
}
})
watch(selectedAnimation, (newValue) => {
if (load3d && newValue !== undefined) {
load3d.updateSelectedAnimation(newValue)
}
})
const handleSeek = (progress: number) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
load3d.setAnimationTime(time)
}
}
const setupAnimationEvents = () => {
if (!load3d) return
load3d.addEventListener(
'animationListChange',
(newValue: AnimationItem[]) => {
animations.value = newValue
}
)
load3d.addEventListener(
'animationProgressChange',
(data: { progress: number; currentTime: number; duration: number }) => {
animationProgress.value = data.progress
animationDuration.value = data.duration
}
)
// Initialize animation list if animations already exist
if (load3d.hasAnimations()) {
const clips = load3d.animationManager.animationClips
animations.value = clips.map((clip, index) => ({
name: clip.name || `Animation ${index + 1}`,
index
}))
animationDuration.value = load3d.getAnimationDuration()
}
}
/**
* Initialize viewer in node mode (with source Load3d)
*/
@@ -270,6 +334,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
upDirection: upDirection.value,
materialMode: materialMode.value
}
setupAnimationEvents()
} catch (error) {
console.error('Error initializing Load3d viewer:', error)
useToastStore().addAlert(
@@ -310,6 +376,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isPlyModel.value = load3d.isPlyModel()
isPreview.value = true
setupAnimationEvents()
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
@@ -527,6 +595,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isSplatModel,
isPlyModel,
// Animation state
animations,
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
animationDuration,
// Methods
initializeViewer,
initializeStandaloneViewer,
@@ -539,6 +615,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
refreshViewport,
handleBackgroundImageUpdate,
handleModelDrop,
handleSeek,
cleanup
}
}

View File

@@ -125,6 +125,13 @@ export class AnimationManager implements AnimationManagerInterface {
}
this.animationActions = [action]
// Emit initial progress to set duration
this.eventManager.emitEvent('animationProgressChange', {
progress: 0,
currentTime: 0,
duration: clip.duration
})
}
toggleAnimation(play?: boolean): void {
@@ -150,8 +157,58 @@ export class AnimationManager implements AnimationManagerInterface {
update(delta: number): void {
if (this.currentAnimation && this.isAnimationPlaying) {
this.currentAnimation.update(delta)
if (this.animationActions.length > 0) {
const action = this.animationActions[0]
const clip = action.getClip()
const progress = (action.time / clip.duration) * 100
this.eventManager.emitEvent('animationProgressChange', {
progress,
currentTime: action.time,
duration: clip.duration
})
}
}
}
getAnimationTime(): number {
if (this.animationActions.length === 0) return 0
return this.animationActions[0].time
}
getAnimationDuration(): number {
if (this.animationActions.length === 0) return 0
return this.animationActions[0].getClip().duration
}
setAnimationTime(time: number): void {
if (this.animationActions.length === 0) return
const duration = this.getAnimationDuration()
const clampedTime = Math.max(0, Math.min(time, duration))
// Temporarily unpause to allow time update, then restore
const wasPaused = this.animationActions.map((action) => action.paused)
this.animationActions.forEach((action) => {
action.paused = false
action.time = clampedTime
})
if (this.currentAnimation) {
this.currentAnimation.setTime(clampedTime)
this.currentAnimation.update(0)
}
// Restore paused state
this.animationActions.forEach((action, i) => {
action.paused = wasPaused[i]
})
this.eventManager.emitEvent('animationProgressChange', {
progress: (clampedTime / duration) * 100,
currentTime: clampedTime,
duration
})
}
reset(): void {}
}

View File

@@ -726,6 +726,19 @@ class Load3d {
return this.animationManager.animationClips.length > 0
}
public getAnimationTime(): number {
return this.animationManager.getAnimationTime()
}
public getAnimationDuration(): number {
return this.animationManager.getAnimationDuration()
}
public setAnimationTime(time: number): void {
this.animationManager.setAnimationTime(time)
this.forceRender()
}
public remove(): void {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()

View File

@@ -34,9 +34,26 @@ class Load3dUtils {
return await resp.json()
}
static readonly MAX_UPLOAD_SIZE_MB = 100
static async uploadFile(file: File, subfolder: string) {
let uploadPath
const fileSizeMB = file.size / 1024 / 1024
if (fileSizeMB > this.MAX_UPLOAD_SIZE_MB) {
const message = t('toastMessages.fileTooLarge', {
size: fileSizeMB.toFixed(1),
maxSize: this.MAX_UPLOAD_SIZE_MB
})
console.warn(
'[Load3D] uploadFile: file too large',
fileSizeMB.toFixed(2),
'MB'
)
useToastStore().addAlert(message)
return undefined
}
try {
const body = new FormData()
body.append('image', file)
@@ -61,7 +78,7 @@ class Load3dUtils {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
console.error('Upload error:', error)
console.error('[Load3D] uploadFile: exception', error)
useToastStore().addAlert(
error instanceof Error
? error.message

View File

@@ -3,9 +3,10 @@ import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
import OBJLoader2WorkerUrl from 'wwobjloader2/worker?url'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -22,7 +23,7 @@ import { FastPLYLoader } from './loader/FastPLYLoader'
export class LoaderManager implements LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
@@ -41,7 +42,12 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager = eventManager
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader()
this.objLoader = new OBJLoader2Parallel()
// Set worker URL for Vite compatibility
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
@@ -173,7 +179,9 @@ export class LoaderManager implements LoaderManagerInterface {
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
this.objLoader.setMaterials(materials)
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
} catch (e) {
console.log(
'No MTL file found or error loading it, continuing without materials'
@@ -181,8 +189,10 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
this.objLoader.setPath(path)
model = await this.objLoader.loadAsync(filename)
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
const objUrl = path + encodeURIComponent(filename)
model = await this.objLoader.loadAsync(objUrl)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
@@ -193,7 +203,6 @@ export class LoaderManager implements LoaderManagerInterface {
case 'gltf':
case 'glb':
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
this.modelManager.setOriginalModel(gltf)

View File

@@ -4,8 +4,8 @@ import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { type OBJLoader2Parallel } from 'wwobjloader2'
export type MaterialMode =
| 'original'
@@ -146,6 +146,9 @@ export interface AnimationManagerInterface extends BaseManager {
updateSelectedAnimation(index: number): void
toggleAnimation(play?: boolean): void
update(delta: number): void
getAnimationTime(): number
getAnimationDuration(): number
setAnimationTime(time: number): void
}
export interface ModelManagerInterface {
@@ -176,7 +179,7 @@ export interface ModelManagerInterface {
export interface LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader
objLoader: OBJLoader2Parallel
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader

View File

@@ -37,6 +37,15 @@ function isOpened(): boolean {
return useDialogStore().isDialogOpen('global-mask-editor')
}
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
if (!isOpened()) return
const store = useMaskEditorStore()
const oldBrushSize = store.brushSettings.size
const newBrushSize = sizeChanger(oldBrushSize)
store.setBrushSize(newBrushSize)
}
app.registerExtension({
name: 'Comfy.MaskEditor',
settings: [
@@ -82,13 +91,24 @@ app.registerExtension({
id: 'Comfy.MaskEditor.BrushSize.Increase',
icon: 'pi pi-plus-circle',
label: 'Increase Brush Size in MaskEditor',
function: () => changeBrushSize((old) => _.clamp(old + 4, 1, 100))
function: () => changeBrushSize((old) => _.clamp(old + 2, 1, 250))
},
{
id: 'Comfy.MaskEditor.BrushSize.Decrease',
icon: 'pi pi-minus-circle',
label: 'Decrease Brush Size in MaskEditor',
function: () => changeBrushSize((old) => _.clamp(old - 4, 1, 100))
function: () => changeBrushSize((old) => _.clamp(old - 2, 1, 250))
},
{
id: 'Comfy.MaskEditor.ColorPicker',
icon: 'pi pi-palette',
label: 'Open Color Picker in MaskEditor',
function: () => {
if (!isOpened()) return
const store = useMaskEditorStore()
store.colorInput?.click()
}
}
],
init() {
@@ -101,12 +121,3 @@ app.registerExtension({
)
}
})
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
if (!isOpened()) return
const store = useMaskEditorStore()
const oldBrushSize = store.brushSettings.size
const newBrushSize = sizeChanger(oldBrushSize)
store.setBrushSize(newBrushSize)
}

View File

@@ -200,6 +200,9 @@
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Increase Brush Size in MaskEditor"
},
"Comfy_MaskEditor_ColorPicker": {
"label": "Open Color Picker in MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"
},

View File

@@ -508,10 +508,12 @@
"title": "Choose your hardware setup",
"recommended": "RECOMMENDED",
"nvidiaSubtitle": "NVIDIA CUDA",
"amdSubtitle": "AMD ROCm™",
"cpuSubtitle": "CPU Mode",
"manualSubtitle": "Manual Setup",
"appleMetalDescription": "Leverages your Mac's GPU for faster speed and a better overall experience",
"nvidiaDescription": "Use your NVIDIA GPU with CUDA acceleration for the best performance.",
"amdDescription": "Use your AMD GPU with ROCm™ acceleration for the best performance.",
"cpuDescription": "Use CPU mode for compatibility when GPU acceleration is not available",
"manualDescription": "Configure ComfyUI manually for advanced setups or unsupported hardware"
},
@@ -1035,6 +1037,8 @@
"copyErrorMessage": "Copy error message",
"reportError": "Report error"
},
"toggleJobHistory": "Toggle Job History",
"jobHistory": "Job History",
"jobList": {
"undated": "Undated",
"sortMostRecent": "Most recent",
@@ -1137,6 +1141,7 @@
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
"Open Color Picker in MaskEditor": "Open Color Picker in MaskEditor",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"Unload Models": "Unload Models",
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
@@ -1390,6 +1395,8 @@
"conditioning": "conditioning",
"loaders": "loaders",
"guiders": "guiders",
"latent": "latent",
"mask": "mask",
"api node": "api node",
"video": "video",
"ByteDance": "ByteDance",
@@ -1406,17 +1413,16 @@
"kandinsky5": "kandinsky5",
"hooks": "hooks",
"combine": "combine",
"logic": "logic",
"cond single": "cond single",
"context": "context",
"controlnet": "controlnet",
"inpaint": "inpaint",
"scheduling": "scheduling",
"create": "create",
"mask": "mask",
"deprecated": "deprecated",
"debug": "debug",
"model": "model",
"latent": "latent",
"3d": "3d",
"ltxv": "ltxv",
"qwen": "qwen",
@@ -1485,6 +1491,9 @@
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
"COMBO": "COMBO",
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "CONDITIONING",
"CONTROL_NET": "CONTROL_NET",
"FLOAT": "FLOAT",
@@ -1694,6 +1703,7 @@
"pleaseSelectNodesToGroup": "Please select the nodes (or other groups) to create a group for",
"emptyCanvas": "Empty canvas",
"fileUploadFailed": "File upload failed",
"fileTooLarge": "File too large ({size} MB). Maximum supported size is {maxSize} MB",
"unableToGetModelFilePath": "Unable to get model file path",
"couldNotDetermineFileType": "Could not determine file type",
"errorLoadingModel": "Error loading model",

View File

@@ -267,6 +267,45 @@
}
}
},
"BatchImagesNode": {
"display_name": "Batch Images",
"inputs": {
"images": {
"name": "images"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"BatchLatentsNode": {
"display_name": "Batch Latents",
"inputs": {
"latents": {
"name": "latents"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"BatchMasksNode": {
"display_name": "Batch Masks",
"inputs": {
"masks": {
"name": "masks"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"BetaSamplingScheduler": {
"display_name": "BetaSamplingScheduler",
"inputs": {
@@ -519,7 +558,7 @@
}
},
"ByteDanceSeedreamNode": {
"display_name": "ByteDance Seedream 4",
"display_name": "ByteDance Seedream 4.5",
"description": "Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
"inputs": {
"model": {
@@ -1247,6 +1286,26 @@
}
}
},
"ComfySwitchNode": {
"display_name": "Switch",
"inputs": {
"switch": {
"name": "switch"
},
"on_false": {
"name": "on_false"
},
"on_true": {
"name": "on_true"
}
},
"outputs": {
"0": {
"name": "output",
"tooltip": null
}
}
},
"ConditioningAverage": {
"display_name": "ConditioningAverage",
"inputs": {
@@ -1956,6 +2015,20 @@
}
}
},
"CustomCombo": {
"display_name": "Custom Combo",
"inputs": {
"choice": {
"name": "choice"
},
"option0": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"DiffControlNetLoader": {
"display_name": "Load ControlNet Model (diff)",
"inputs": {
@@ -4776,6 +4849,9 @@
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 4 additional reference images."
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -4808,6 +4884,9 @@
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 6 additional reference images."
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -4864,6 +4943,9 @@
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 7 reference images."
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -4888,6 +4970,9 @@
},
"duration": {
"name": "duration"
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -4923,6 +5008,9 @@
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 4 additional reference images."
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -6458,7 +6546,7 @@
}
},
"Mahiro": {
"display_name": "Mahiro is so cute that she deserves a better guidance function!! (。・ω・。)",
"display_name": "Mahiro CFG",
"description": "Modify the guidance to scale more on the 'direction' of the positive prompt rather than the difference between the negative prompt.",
"inputs": {
"model": {
@@ -10632,6 +10720,29 @@
}
}
},
"ResizeImageMaskNode": {
"display_name": "Resize Image/Mask",
"inputs": {
"input": {
"name": "input"
},
"resize_type": {
"name": "resize_type"
},
"scale_method": {
"name": "scale_method"
},
"resize_type_multiplier": {
"name": "multiplier"
}
},
"outputs": {
"0": {
"name": "resized",
"tooltip": null
}
}
},
"ResizeImagesByLongerEdge": {
"display_name": "Resize Images by Longer Edge",
"inputs": {

View File

@@ -80,6 +80,9 @@
"Comfy_Canvas_ToggleMinimap": {
"label": "画布切换小地图"
},
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "固定/取消固定选中项"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "忽略/取消忽略选中节点"
},
@@ -92,9 +95,6 @@
"Comfy_Canvas_ToggleSelectedNodes_Pin": {
"label": "固定/取消固定选中节点"
},
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "固定/取消固定选中项"
},
"Comfy_Canvas_Unlock": {
"label": "解锁画布"
},
@@ -290,6 +290,9 @@
"Workspace_ToggleBottomPanel": {
"label": "切换底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "显示快捷键对话框"
},
"Workspace_ToggleBottomPanelTab_command-terminal": {
"label": "切换终端底部面板"
},
@@ -302,9 +305,6 @@
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切换检视控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "显示快捷键对话框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"
},
@@ -324,4 +324,4 @@
"label": "切换工作流侧边栏",
"tooltip": "工作流"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,11 @@
},
"Comfy-Desktop_WindowStyle": {
"name": "窗口样式",
"tooltip": "选择自定义选项以隐藏系统标题栏",
"options": {
"custom": "自定义",
"default": "默认"
},
"tooltip": "选择自定义选项以隐藏系统标题栏"
"default": "默认",
"custom": "自定义"
}
},
"Comfy_Canvas_BackgroundImage": {
"name": "画布背景图像",
@@ -46,9 +46,9 @@
"Comfy_Canvas_NavigationMode": {
"name": "画布导航模式",
"options": {
"Custom": "自定义",
"Standard (New)": "标准(新)",
"Drag Navigation": "拖动画布",
"Standard (New)": "标准(新)"
"Custom": "自定义"
}
},
"Comfy_Canvas_SelectionToolbox": {
@@ -81,14 +81,14 @@
},
"Comfy_Execution_PreviewMethod": {
"name": "实时预览",
"tooltip": "图像生成过程中实时预览。 \"默认\" 使用服务器 CLI 设置。",
"options": {
"auto": "自动",
"default": "默认",
"latent2rgb": "latent2rgb",
"none": "无",
"auto": "自动",
"latent2rgb": "latent2rgb",
"taesd": "taesd"
},
"tooltip": "图像生成过程中实时预览。 \"默认\" 使用服务器 CLI 设置。"
}
},
"Comfy_FloatRoundingPrecision": {
"name": "浮点组件四舍五入的小数位数 [0 = 自动]。",
@@ -106,9 +106,9 @@
"Comfy_Graph_LinkMarkers": {
"name": "连线中点标记",
"options": {
"Arrow": "箭头",
"None": "",
"Circle": "圆",
"None": ""
"Arrow": "箭头"
}
},
"Comfy_Graph_LiveSelection": {
@@ -128,25 +128,25 @@
"name": "释放连线时的操作",
"options": {
"context menu": "上下文菜单",
"no action": "无操作",
"search box": "搜索框"
"search box": "搜索框",
"no action": "无操作"
}
},
"Comfy_LinkRelease_ActionShift": {
"name": "释放连线时的操作Shift",
"options": {
"context menu": "上下文菜单",
"no action": "无操作",
"search box": "搜索框"
"search box": "搜索框",
"no action": "无操作"
}
},
"Comfy_LinkRenderMode": {
"name": "连线渲染样式",
"options": {
"Hidden": "隐藏",
"Straight": "直角线",
"Linear": "直线",
"Spline": "曲线",
"Straight": "直角线"
"Hidden": "隐藏"
}
},
"Comfy_Load3D_3DViewerEnable": {
@@ -159,11 +159,11 @@
},
"Comfy_Load3D_CameraType": {
"name": "摄像机类型",
"tooltip": "控制创建新的3D小部件时默认的相机是透视还是正交。这个默认设置仍然可以在创建后为每个小部件单独切换。",
"options": {
"orthographic": "正交",
"perspective": "透视"
},
"tooltip": "控制创建新的3D小部件时默认的相机是透视还是正交。这个默认设置仍然可以在创建后为每个小部件单独切换。"
"perspective": "透视",
"orthographic": "正交"
}
},
"Comfy_Load3D_LightAdjustmentIncrement": {
"name": "光照调整步长",
@@ -183,12 +183,12 @@
},
"Comfy_Load3D_PLYEngine": {
"name": "PLY 引擎",
"tooltip": "选择加载 PLY 文件的引擎。 \"threejs\" 使用原生 Three.js PLY 加载器(最适合网格 PLY。 \"fastply\" 使用专用于 ASCII 点云的 PLY 文件加载器。 \"sparkjs\" 使用 Spark.js 加载 3D 高斯泼溅 PLY 文件。",
"options": {
"threejs": "threejs",
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "选择加载 PLY 文件的引擎。 \"threejs\" 使用原生 Three.js PLY 加载器(最适合网格 PLY。 \"fastply\" 使用专用于 ASCII 点云的 PLY 文件加载器。 \"sparkjs\" 使用 Spark.js 加载 3D 高斯泼溅 PLY 文件。"
"sparkjs": "sparkjs"
}
},
"Comfy_Load3D_ShowGrid": {
"name": "显示网格",
@@ -211,11 +211,11 @@
},
"Comfy_ModelLibrary_NameFormat": {
"name": "在模型库树视图中显示的名称",
"tooltip": "选择“文件名”以在模型列表中显示原始文件名的简化视图(不带目录和“.safetensors”后缀名。选择“标题”以显示可配置的模型元数据标题。",
"options": {
"filename": "文件名",
"title": "标题"
},
"tooltip": "选择“文件名”以在模型列表中显示原始文件名的简化视图(不带目录和“.safetensors”后缀名。选择“标题”以显示可配置的模型元数据标题。"
}
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "在图像预览下方显示宽度×高度"
@@ -266,9 +266,9 @@
"Comfy_NodeBadge_NodeSourceBadgeMode": {
"name": "节点源标签",
"options": {
"Hide built-in": "仅第三方",
"None": "无",
"Show all": "显示全部"
"Show all": "显示全部",
"Hide built-in": "仅第三方"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
@@ -349,8 +349,8 @@
"Comfy_Sidebar_Style": {
"name": "侧边栏样式",
"options": {
"connected": "连接式",
"floating": "浮动式"
"floating": "浮动式",
"connected": "连接式"
}
},
"Comfy_Sidebar_UnifiedWidth": {
@@ -371,11 +371,11 @@
},
"Comfy_UseNewMenu": {
"name": "使用新菜单",
"tooltip": "选单列位置。在行动装置上,选单始终显示于顶端。",
"options": {
"Disabled": "禁用",
"Top": "顶部"
},
"tooltip": "选单列位置。在行动装置上,选单始终显示于顶端。"
}
},
"Comfy_Validation_Workflows": {
"name": "校验工作流"
@@ -390,11 +390,11 @@
},
"Comfy_WidgetControlMode": {
"name": "组件控制模式",
"tooltip": "控制组件值的更新时机(随机/增加/减少),可以在执行工作流之前或之后。",
"options": {
"after": "之",
"before": "之"
},
"tooltip": "控制组件值的更新时机(随机/增加/减少),可以在执行工作流之前或之后。"
"before": "之",
"after": "之"
}
},
"Comfy_Window_UnloadConfirmation": {
"name": "关闭窗口时显示确认"
@@ -402,8 +402,8 @@
"Comfy_Workflow_AutoSave": {
"name": "自动保存",
"options": {
"after delay": "延迟后",
"off": "关闭"
"off": "关闭",
"after delay": "延迟后"
}
},
"Comfy_Workflow_AutoSaveDelay": {

View File

@@ -37,6 +37,8 @@ interface ModelTypeOption {
value: string // Actual tag value
}
const DISALLOWED_MODEL_TYPES = ['nlf'] as const
/**
* Composable for fetching and managing model types from the API
* Uses shared state to ensure data is only fetched once
@@ -51,6 +53,12 @@ export const useModelTypes = createSharedComposable(() => {
async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders()
return response
.filter(
(folder) =>
!DISALLOWED_MODEL_TYPES.includes(
folder.name as (typeof DISALLOWED_MODEL_TYPES)[number]
)
)
.map((folder) => ({
name: formatDisplayName(folder.name),
value: folder.name

View File

@@ -258,6 +258,8 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import {
FirebaseAuthStoreError,
@@ -269,8 +271,6 @@ type SubscriptionTier = components['schemas']['SubscriptionTier']
type CheckoutTierKey = Exclude<TierKey, 'founder'>
type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly`
type BillingCycle = 'monthly' | 'yearly'
const getCheckoutTier = (
tierKey: CheckoutTierKey,
billingCycle: BillingCycle
@@ -342,6 +342,15 @@ const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
)
const currentPlanDescriptor = computed(() => {
if (!currentTierKey.value) return null
return {
tierKey: currentTierKey.value,
billingCycle: isYearlySubscription.value ? 'yearly' : 'monthly'
} as const
})
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
if (!currentTierKey.value) return false
@@ -443,7 +452,23 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
if (isActiveSubscription.value) {
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
await accessBillingPortal(checkoutTier)
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
}
const downgrade =
currentPlanDescriptor.value &&
isPlanDowngrade({
current: currentPlanDescriptor.value,
target: targetPlan
})
if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
} else {
await accessBillingPortal(checkoutTier)
}
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { getPlanRank, isPlanDowngrade } from './subscriptionTierRank'
describe('subscriptionTierRank', () => {
it('returns consistent order for ranked plans', () => {
const yearlyPro = getPlanRank({ tierKey: 'pro', billingCycle: 'yearly' })
const monthlyStandard = getPlanRank({
tierKey: 'standard',
billingCycle: 'monthly'
})
expect(yearlyPro).toBeLessThan(monthlyStandard)
})
it('identifies downgrades correctly', () => {
const result = isPlanDowngrade({
current: { tierKey: 'pro', billingCycle: 'yearly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
expect(result).toBe(true)
})
it('treats lateral or upgrade moves as non-downgrades', () => {
expect(
isPlanDowngrade({
current: { tierKey: 'standard', billingCycle: 'monthly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
).toBe(false)
expect(
isPlanDowngrade({
current: { tierKey: 'creator', billingCycle: 'monthly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
).toBe(false)
})
it('treats unknown plans (e.g., founder) as non-downgrade cases', () => {
const result = isPlanDowngrade({
current: { tierKey: 'founder', billingCycle: 'monthly' },
target: { tierKey: 'standard', billingCycle: 'monthly' }
})
expect(result).toBe(false)
})
})

View File

@@ -0,0 +1,58 @@
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
export type BillingCycle = 'monthly' | 'yearly'
type RankedTierKey = Exclude<TierKey, 'founder'>
type RankedPlanKey = `${BillingCycle}-${RankedTierKey}`
interface PlanDescriptor {
tierKey: TierKey
billingCycle: BillingCycle
}
const PLAN_ORDER: RankedPlanKey[] = [
'yearly-pro',
'yearly-creator',
'yearly-standard',
'monthly-pro',
'monthly-creator',
'monthly-standard'
]
const PLAN_RANK = PLAN_ORDER.reduce<Map<RankedPlanKey, number>>(
(acc, plan, index) => acc.set(plan, index),
new Map()
)
const toRankedPlanKey = (
tierKey: TierKey,
billingCycle: BillingCycle
): RankedPlanKey | null => {
if (tierKey === 'founder') return null
return `${billingCycle}-${tierKey}` as RankedPlanKey
}
export const getPlanRank = ({
tierKey,
billingCycle
}: PlanDescriptor): number => {
const planKey = toRankedPlanKey(tierKey, billingCycle)
if (!planKey) return Number.POSITIVE_INFINITY
return PLAN_RANK.get(planKey) ?? Number.POSITIVE_INFINITY
}
interface DowngradeCheckParams {
current: PlanDescriptor
target: PlanDescriptor
}
export const isPlanDowngrade = ({
current,
target
}: DowngradeCheckParams): boolean => {
const currentRank = getPlanRank(current)
const targetRank = getPlanRank(target)
return targetRank > currentRank
}

View File

@@ -203,10 +203,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
})
const gridTemplateRows = computed((): string => {
const widgets = toValue(processedWidgets)
return widgets
.filter((w) => !w.simplified.options?.hidden)
.map((w) => (shouldExpand(w.type) ? 'auto' : 'min-content'))
if (!nodeData?.widgets) return ''
const processedNames = new Set(toValue(processedWidgets).map((w) => w.name))
return nodeData.widgets
.filter((w) => processedNames.has(w.name) && !w.options?.hidden)
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
})
</script>

View File

@@ -31,10 +31,17 @@ const precision = computed(() => {
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
// Use step2 (correct input spec value) if available
if (props.widget.options?.step2 !== undefined) {
return Number(props.widget.options.step2)
}
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
// We skip default step values (1, 10) to avoid affecting normal widgets
const step = props.widget.options?.step
if (step !== undefined && step > 10) {
return Number(step) / 10
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {

View File

@@ -17,6 +17,7 @@ const props = defineProps<{
}>()
const canvasEl = ref()
const containerHeight = ref(20)
const canvas: LGraphCanvas = useCanvasStore().canvas as LGraphCanvas
let node: LGraphNode | undefined
@@ -52,9 +53,19 @@ onBeforeUnmount(() => {
function draw() {
if (!widgetInstance || !node) return
const width = canvasEl.value.parentElement.clientWidth
const height = widgetInstance.computeSize
? widgetInstance.computeSize(width)[1]
: 20
// Priority: computedHeight (from litegraph) > computeLayoutSize > computeSize
let height = 20
if (widgetInstance.computedHeight) {
height = widgetInstance.computedHeight
} else if (widgetInstance.computeLayoutSize) {
height = widgetInstance.computeLayoutSize(node).minHeight
} else if (widgetInstance.computeSize) {
height = widgetInstance.computeSize(width)[1]
}
containerHeight.value = height
// Set node.canvasHeight for legacy widgets that use it (e.g., Impact Pack)
// @ts-expect-error canvasHeight is a custom property used by some extensions
node.canvasHeight = height
widgetInstance.y = 0
canvasEl.value.height = (height + 2) * scaleFactor
canvasEl.value.width = width * scaleFactor
@@ -87,7 +98,10 @@ function handleMove(e: PointerEvent) {
}
</script>
<template>
<div class="relative mx-[-12px] min-w-0 basis-0">
<div
class="relative mx-[-12px] min-w-0 basis-0"
:style="{ minHeight: `${containerHeight}px` }"
>
<canvas
ref="canvasEl"
class="absolute mt-[-13px] w-full cursor-crosshair"

View File

@@ -2,6 +2,7 @@ import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface NumberWidgetOptions {
step?: number
step2?: number
precision?: number
}
@@ -17,10 +18,17 @@ export function useNumberStepCalculation(
) {
return computed(() => {
const precision = toValue(precisionArg)
// Use step2 (correct input spec value) instead of step (legacy 10x value)
// Use step2 (correct input spec value) if available
if (options?.step2 !== undefined) {
return Number(options.step2)
}
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
// We skip default step values (1, 10) to avoid affecting normal widgets
const step = options?.step
if (step !== undefined && step > 10) {
return Number(step) / 10
}
if (precision === undefined) {
return returnUndefinedForDefault ? undefined : 0
@@ -29,7 +37,9 @@ export function useNumberStepCalculation(
if (precision === 0) return 1
// For precision > 0, step = 1 / (10^precision)
const step = 1 / Math.pow(10, precision)
return returnUndefinedForDefault ? step : Number(step.toFixed(precision))
const calculatedStep = 1 / Math.pow(10, precision)
return returnUndefinedForDefault
? calculatedStep
: Number(calculatedStep.toFixed(precision))
})
}

View File

@@ -1,4 +1,5 @@
import { toRaw } from 'vue'
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils'
import { nodeToLoad3dMap } from '@/composables/useLoad3d'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
@@ -75,13 +76,20 @@ export class Load3dService {
const sourceModel = source.modelManager.currentModel
if (sourceModel) {
// Remove existing model from target scene before adding new one
const existingModel = target.getModelManager().currentModel
if (existingModel) {
target.getSceneManager().scene.remove(existingModel)
}
if (source.isSplatModel()) {
const originalURL = source.modelManager.originalURL
if (originalURL) {
await target.loadModel(originalURL)
}
} else {
const modelClone = sourceModel.clone()
// Use SkeletonUtils.clone for proper skeletal animation support
const modelClone = SkeletonUtils.clone(sourceModel)
target.getModelManager().currentModel = modelClone
target.getSceneManager().scene.add(modelClone)
@@ -105,6 +113,14 @@ export class Load3dService {
target.getModelManager().appliedTexture =
source.getModelManager().appliedTexture
}
// Copy animation state
if (source.hasAnimations()) {
target.animationManager.setupModelAnimations(
modelClone,
sourceOriginalModel
)
}
}
}

View File

@@ -73,6 +73,8 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
const tgpuRoot = ref<any>(null)
const colorInput = ref<HTMLInputElement | null>(null)
watch(maskCanvas, (canvas) => {
if (canvas) {
maskCtx.value = canvas.getContext('2d', { willReadFrequently: true })
@@ -113,7 +115,7 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
})
function setBrushSize(size: number): void {
brushSettings.value.size = _.clamp(size, 1, 500)
brushSettings.value.size = _.clamp(size, 1, 250)
}
function setBrushOpacity(opacity: number): void {
@@ -252,6 +254,8 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
tgpuRoot,
colorInput,
setBrushSize,
setBrushOpacity,
setBrushHardness,

View File

@@ -618,3 +618,13 @@ export const useQueueSettingsStore = defineStore('queueSettingsStore', {
batchCount: 1
})
})
export const useQueueUIStore = defineStore('queueUIStore', () => {
const isOverlayExpanded = ref(false)
function toggleOverlay() {
isOverlayExpanded.value = !isOverlayExpanded.value
}
return { isOverlayExpanded, toggleOverlay }
})