mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Merge branch 'main' into pysssss/fix-panel-node-title-reactivity
This commit is contained in:
@@ -83,7 +83,7 @@ test.describe('Templates', () => {
|
||||
|
||||
await comfyPage.page
|
||||
.locator(
|
||||
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
|
||||
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
|
||||
)
|
||||
.click()
|
||||
await comfyPage.templates.loadTemplate('default')
|
||||
|
||||
66
docs/TEMPLATE_RANKING.md
Normal file
66
docs/TEMPLATE_RANKING.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Template Ranking System
|
||||
|
||||
Usage-based ordering for workflow templates with position bias normalization.
|
||||
|
||||
Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search).
|
||||
|
||||
## Sort Modes
|
||||
|
||||
| Mode | Formula | Description |
|
||||
| -------------- | ------------------------------------------------ | ---------------------- |
|
||||
| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation |
|
||||
| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven |
|
||||
| `newest` | Date sort | Existing |
|
||||
| `alphabetical` | Name sort | Existing |
|
||||
|
||||
Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1.
|
||||
|
||||
## Data Files
|
||||
|
||||
**Usage scores** (generated from Mixpanel):
|
||||
|
||||
```json
|
||||
// In templates/index.json, add to any template:
|
||||
{
|
||||
"name": "some_template",
|
||||
"usage": 1000,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Search rank** (set per-template in workflow_templates repo):
|
||||
|
||||
```json
|
||||
// In templates/index.json, add to any template:
|
||||
{
|
||||
"name": "some_template",
|
||||
"searchRank": 8, // Scale 1-10, default 5
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
| searchRank | Effect |
|
||||
| ---------- | ---------------------------- |
|
||||
| 1-4 | Demote (bury in results) |
|
||||
| 5 | Neutral (default if not set) |
|
||||
| 6-10 | Promote (boost in results) |
|
||||
|
||||
## Position Bias Correction
|
||||
|
||||
Raw usage reflects true preference AND UI position bias. We use linear interpolation:
|
||||
|
||||
```
|
||||
correction = 1 + (position - 1) / (maxPosition - 1)
|
||||
normalizedUsage = rawUsage × correction
|
||||
```
|
||||
|
||||
| Position | Boost |
|
||||
| -------- | ----- |
|
||||
| 1 | 1.0× |
|
||||
| 50 | 1.28× |
|
||||
| 100 | 1.57× |
|
||||
| 175 | 2.0× |
|
||||
|
||||
Templates buried at the bottom get up to 2× boost to compensate for reduced visibility.
|
||||
|
||||
---
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.37.3",
|
||||
"version": "1.37.5",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
@config '../../tailwind.config.ts';
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@theme {
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
>
|
||||
<slot name="actions" :node="props.node" />
|
||||
</div>
|
||||
|
||||
@@ -175,6 +175,7 @@
|
||||
<!-- Actual Template Cards -->
|
||||
<CardContainer
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="compact"
|
||||
@@ -405,6 +406,8 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
@@ -423,6 +426,30 @@ onMounted(() => {
|
||||
sessionStartTime.value = Date.now()
|
||||
})
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const distributions = computed(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
switch (__DISTRIBUTION__) {
|
||||
case 'cloud':
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
case 'localhost':
|
||||
return [TemplateIncludeOnDistributionEnum.Local]
|
||||
case 'desktop':
|
||||
default:
|
||||
if (systemStatsStore.systemStats?.system.os === 'darwin') {
|
||||
return [
|
||||
TemplateIncludeOnDistributionEnum.Desktop,
|
||||
TemplateIncludeOnDistributionEnum.Mac
|
||||
]
|
||||
}
|
||||
return [
|
||||
TemplateIncludeOnDistributionEnum.Desktop,
|
||||
TemplateIncludeOnDistributionEnum.Windows
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Wrap onClose to track session end
|
||||
const onClose = () => {
|
||||
if (isCloud) {
|
||||
@@ -511,6 +538,9 @@ const allTemplates = computed(() => {
|
||||
return workflowTemplatesStore.enhancedTemplates
|
||||
})
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
|
||||
// Filter templates based on selected navigation item
|
||||
const navigationFilteredTemplates = computed(() => {
|
||||
if (!selectedNavItem.value) {
|
||||
@@ -536,6 +566,36 @@ const {
|
||||
resetFilters
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
* Coordinates state between the selected navigation item and the sort order to
|
||||
* create deterministic, predictable behavior.
|
||||
* @param source The origin of the change ('nav' or 'sort').
|
||||
*/
|
||||
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
const isPopularNav = selectedNavItem.value === 'popular'
|
||||
const isPopularSort = sortBy.value === 'popular'
|
||||
|
||||
if (source === 'nav') {
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
|
||||
sortBy.value = 'popular'
|
||||
} else if (!isPopularNav && isPopularSort) {
|
||||
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
} else if (source === 'sort') {
|
||||
// When sort is changed away from 'Popular' while in the 'Popular' category,
|
||||
// reset the category to 'All Templates' to avoid a confusing state.
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
selectedNavItem.value = 'all'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
|
||||
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
|
||||
watch(sortBy, () => coordinateNavAndSort('sort'))
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
const selectedModelObjects = computed({
|
||||
get() {
|
||||
@@ -578,9 +638,6 @@ const cardRefs = ref<HTMLElement[]>([])
|
||||
// Force re-render key for templates when sorting changes
|
||||
const templateListKey = ref(0)
|
||||
|
||||
// Navigation
|
||||
const selectedNavItem = ref<string | null>('all')
|
||||
|
||||
// Search text for model filter
|
||||
const modelSearchText = ref<string>('')
|
||||
|
||||
@@ -645,11 +702,19 @@ const runsOnFilterLabel = computed(() => {
|
||||
|
||||
// Sort options
|
||||
const sortOptions = computed(() => [
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.default', 'Default'),
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.recommended', 'Recommended'),
|
||||
value: 'recommended'
|
||||
},
|
||||
{
|
||||
name: t('templateWorkflows.sort.popular', 'Popular'),
|
||||
value: 'popular'
|
||||
},
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
{
|
||||
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
value: 'vram-low-to-high'
|
||||
@@ -750,7 +815,7 @@ const pageTitle = computed(() => {
|
||||
// Initialize templates loading with useAsyncState
|
||||
const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
// Run both operations in parallel for better performance
|
||||
// Run all operations in parallel for better performance
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
@@ -763,6 +828,14 @@ const { isLoading } = useAsyncState(
|
||||
}
|
||||
)
|
||||
|
||||
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
|
||||
return (template.includeOnDistributions?.length ?? 0) > 0
|
||||
? distributions.value.some((d) =>
|
||||
template.includeOnDistributions?.includes(d)
|
||||
)
|
||||
: true
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
|
||||
@@ -49,25 +49,66 @@
|
||||
@select="selectedCredits = option.credits"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground w-96">
|
||||
{{ $t('credits.topUp.templateNote') }}
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
<i
|
||||
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
>
|
||||
{{ t('subscription.videoTemplateBasedCredits') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buy Button -->
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
@click="handleBuy"
|
||||
<!-- Buy Button -->
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
|
||||
}
|
||||
}"
|
||||
>
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
</Button>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
>
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover } from 'primevue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -101,22 +142,28 @@ const toast = useToast()
|
||||
const selectedCredits = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const popover = ref()
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
|
||||
const creditOptions: CreditOption[] = [
|
||||
{
|
||||
credits: 1055, // $5.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 41 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 30 })
|
||||
},
|
||||
{
|
||||
credits: 2110, // $10.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 82 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 60 })
|
||||
},
|
||||
{
|
||||
credits: 4220, // $20.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 184 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 120 })
|
||||
},
|
||||
{
|
||||
credits: 10550, // $50.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 412 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 301 })
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
v-model:light-config="lightConfig"
|
||||
:is-splat-model="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:has-skeleton="hasSkeleton"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
@@ -116,6 +117,7 @@ const {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -58,8 +58,10 @@
|
||||
v-if="showModelControls"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:up-direction="modelConfig!.upDirection"
|
||||
v-model:show-skeleton="modelConfig!.showSkeleton"
|
||||
:hide-material-mode="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:has-skeleton="hasSkeleton"
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
@@ -99,9 +101,14 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isSplatModel = false, isPlyModel = false } = defineProps<{
|
||||
const {
|
||||
isSplatModel = false,
|
||||
isPlyModel = false,
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
isSplatModel?: boolean
|
||||
isPlyModel?: boolean
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -70,6 +70,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSkeleton">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.showSkeleton'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="cn('rounded-full', showSkeleton && 'bg-blue-500')"
|
||||
:aria-label="t('load3d.showSkeleton')"
|
||||
@click="showSkeleton = !showSkeleton"
|
||||
>
|
||||
<i class="pi pi-sitemap text-lg text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,13 +100,19 @@ import type {
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
const {
|
||||
hideMaterialMode = false,
|
||||
isPlyModel = false,
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
const showSkeleton = defineModel<boolean>('showSkeleton')
|
||||
|
||||
const showUpDirection = ref(false)
|
||||
const showMaterialMode = ref(false)
|
||||
|
||||
@@ -47,11 +47,36 @@
|
||||
<MediaAssetFilterBar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<Divider type="dashed" class="my-2" />
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="handleClearQueue"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
@@ -164,7 +189,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import { Divider } from 'primevue'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -187,17 +212,26 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
@@ -226,6 +260,19 @@ const formattedExecutionTime = computed(() => {
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
})
|
||||
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsCount = computed(
|
||||
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobs',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
@@ -490,6 +537,10 @@ const handleDeleteSelected = async () => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (
|
||||
activeTab.value === 'output' &&
|
||||
|
||||
@@ -67,7 +67,6 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -79,6 +78,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const executionStore = useExecutionStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
@@ -86,6 +86,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
function isQueuePanelV2Enabled() {
|
||||
return settingStore.get('Comfy.Queue.QPOV2')
|
||||
}
|
||||
|
||||
async function toggleQueuePanelV2() {
|
||||
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled())
|
||||
}
|
||||
|
||||
const moveSelectedNodes = (
|
||||
positionUpdater: (pos: Point, gridSize: number) => Point
|
||||
) => {
|
||||
@@ -1191,6 +1199,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleQPOV2',
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Queue Panel V2',
|
||||
function: toggleQueuePanelV2
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
|
||||
@@ -11,10 +11,12 @@ export enum ServerFeatureFlag {
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
|
||||
ASSET_DELETION_ENABLED = 'asset_deletion_enabled',
|
||||
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled'
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,14 +43,16 @@ export function useFeatureFlags() {
|
||||
)
|
||||
)
|
||||
},
|
||||
get assetUpdateOptionsEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
get assetDeletionEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_update_options_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
|
||||
false
|
||||
)
|
||||
remoteConfig.value.asset_deletion_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_DELETION_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get assetRenameEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_rename_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get privateModelsEnabled() {
|
||||
@@ -65,7 +69,6 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get huggingfaceModelImportEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
return (
|
||||
remoteConfig.value.huggingface_model_import_enabled ??
|
||||
api.getServerFeature(
|
||||
@@ -73,6 +76,15 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get asyncModelUploadEnabled() {
|
||||
return (
|
||||
remoteConfig.value.async_model_upload_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.ASYNC_MODEL_UPLOAD_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ describe('useLoad3d', () => {
|
||||
},
|
||||
'Model Config': {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
},
|
||||
'Camera Config': {
|
||||
cameraType: 'perspective',
|
||||
@@ -107,6 +108,8 @@ describe('useLoad3d', () => {
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
hasSkeleton: vi.fn().mockReturnValue(false),
|
||||
setShowSkeleton: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
@@ -143,7 +146,8 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
expect(composable.modelConfig.value).toEqual({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
})
|
||||
expect(composable.cameraConfig.value).toEqual({
|
||||
cameraType: 'perspective',
|
||||
@@ -410,7 +414,8 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(mockNode.properties['Model Config']).toEqual({
|
||||
upDirection: '+y',
|
||||
materialMode: 'wireframe'
|
||||
materialMode: 'wireframe',
|
||||
showSkeleton: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -696,10 +701,13 @@ describe('useLoad3d', () => {
|
||||
'backgroundImageLoadingEnd',
|
||||
'modelLoadingStart',
|
||||
'modelLoadingEnd',
|
||||
'skeletonVisibilityChange',
|
||||
'exportLoadingStart',
|
||||
'exportLoadingEnd',
|
||||
'recordingStatusChange',
|
||||
'animationListChange'
|
||||
'animationListChange',
|
||||
'animationProgressChange',
|
||||
'cameraChanged'
|
||||
]
|
||||
|
||||
expectedEvents.forEach((event) => {
|
||||
|
||||
@@ -40,9 +40,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const modelConfig = ref<ModelConfig>({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
})
|
||||
|
||||
const hasSkeleton = ref(false)
|
||||
|
||||
const cameraConfig = ref<CameraConfig>({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
@@ -273,6 +276,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Model Config'] = newValue
|
||||
load3d.setUpDirection(newValue.upDirection)
|
||||
load3d.setMaterialMode(newValue.materialMode)
|
||||
load3d.setShowSkeleton(newValue.showSkeleton)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -503,6 +507,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
},
|
||||
skeletonVisibilityChange: (value: boolean) => {
|
||||
modelConfig.value.showSkeleton = value
|
||||
},
|
||||
exportLoadingStart: (message: string) => {
|
||||
loadingMessage.value = message || t('load3d.exportingModel')
|
||||
@@ -584,6 +594,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
@@ -19,10 +20,22 @@ const defaultSettingStore = {
|
||||
set: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
const defaultRankingStore = {
|
||||
computeDefaultScore: vi.fn(() => 0),
|
||||
computePopularScore: vi.fn(() => 0),
|
||||
getUsageScore: vi.fn(() => 0),
|
||||
computeFreshness: vi.fn(() => 0.5),
|
||||
isLoaded: { value: false }
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => defaultSettingStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/templateRankingStore', () => ({
|
||||
useTemplateRankingStore: vi.fn(() => defaultRankingStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackTemplateFilterChanged: vi.fn()
|
||||
@@ -34,6 +47,7 @@ const { useTemplateFiltering } =
|
||||
|
||||
describe('useTemplateFiltering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import type { Ref } from 'vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
const rankingStore = useTemplateRankingStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedModels = ref<string[]>(
|
||||
@@ -25,6 +27,8 @@ export function useTemplateFiltering(
|
||||
)
|
||||
const sortBy = ref<
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
@@ -151,10 +155,42 @@ export function useTemplateFiltering(
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
watch(
|
||||
filteredByRunsOn,
|
||||
(templates) => {
|
||||
rankingStore.largestUsageScore = Math.max(
|
||||
...templates.map((t) => t.usage || 0)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const sortedTemplates = computed(() => {
|
||||
const templates = [...filteredByRunsOn.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'recommended':
|
||||
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
|
||||
return templates.sort((a, b) => {
|
||||
const scoreA = rankingStore.computeDefaultScore(
|
||||
a.date,
|
||||
a.searchRank,
|
||||
a.usage
|
||||
)
|
||||
const scoreB = rankingStore.computeDefaultScore(
|
||||
b.date,
|
||||
b.searchRank,
|
||||
b.usage
|
||||
)
|
||||
return scoreB - scoreA
|
||||
})
|
||||
case 'popular':
|
||||
// User-driven: usage × 0.9 + freshness × 0.1
|
||||
return templates.sort((a, b) => {
|
||||
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
|
||||
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
|
||||
return scoreB - scoreA
|
||||
})
|
||||
case 'alphabetical':
|
||||
return templates.sort((a, b) => {
|
||||
const nameA = a.title || a.name || ''
|
||||
@@ -184,7 +220,7 @@ export function useTemplateFiltering(
|
||||
return vramA - vramB
|
||||
})
|
||||
case 'model-size-low-to-high':
|
||||
return templates.sort((a: any, b: any) => {
|
||||
return templates.sort((a, b) => {
|
||||
const sizeA =
|
||||
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
|
||||
const sizeB =
|
||||
@@ -194,7 +230,6 @@ export function useTemplateFiltering(
|
||||
})
|
||||
case 'default':
|
||||
default:
|
||||
// Keep original order (default order)
|
||||
return templates
|
||||
}
|
||||
})
|
||||
@@ -206,7 +241,7 @@ export function useTemplateFiltering(
|
||||
selectedModels.value = []
|
||||
selectedUseCases.value = []
|
||||
selectedRunsOn.value = []
|
||||
sortBy.value = 'newest'
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
|
||||
const removeModelFilter = (model: string) => {
|
||||
|
||||
@@ -94,7 +94,7 @@ function dynamicComboWidget(
|
||||
const newSpec = value ? options[value] : undefined
|
||||
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
remove(node.widgets, isInGroup)
|
||||
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
|
||||
|
||||
if (!newSpec) return
|
||||
|
||||
@@ -341,10 +341,16 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
//TODO: instead apply on output add?
|
||||
//ensure outputs get updated
|
||||
const index = node.inputs.length - 1
|
||||
const input = node.inputs.at(-1)!
|
||||
requestAnimationFrame(() =>
|
||||
node.onConnectionsChange(LiteGraph.INPUT, index, false, undefined, input)
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
const input = node.inputs.at(index)!
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
!!input.link,
|
||||
input.link ? node.graph?.links?.[input.link] : undefined,
|
||||
input
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function autogrowOrdinalToName(
|
||||
@@ -482,7 +488,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
for (const input of toRemove) {
|
||||
const widgetName = input?.widget?.name
|
||||
if (!widgetName) continue
|
||||
remove(node.widgets, (w) => w.name === widgetName)
|
||||
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
|
||||
widget.onRemove?.()
|
||||
}
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
@@ -196,8 +196,7 @@ export class GroupNodeConfig {
|
||||
primitiveToWidget: {}
|
||||
nodeInputs: {}
|
||||
outputVisibility: any[]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
nodeDef: ComfyNodeDef
|
||||
nodeDef: (ComfyNodeDef & { [GROUP]: GroupNodeConfig }) | undefined
|
||||
// @ts-expect-error fixme ts strict error
|
||||
inputs: any[]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -231,8 +230,7 @@ export class GroupNodeConfig {
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
// @ts-expect-error Unused, doesn't exist
|
||||
output_is_hidden: [],
|
||||
output_node: false, // This is a lie (to satisfy the interface)
|
||||
name: source + SEPARATOR + this.name,
|
||||
display_name: this.name,
|
||||
category: 'group nodes' + (SEPARATOR + source),
|
||||
@@ -261,6 +259,7 @@ export class GroupNodeConfig {
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.#convertedToProcess = null
|
||||
if (!this.nodeDef) return
|
||||
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
|
||||
useNodeDefStore().addNodeDef(this.nodeDef)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ export class CameraManager implements CameraManagerInterface {
|
||||
orthographicCamera: THREE.OrthographicCamera
|
||||
activeCamera: THREE.Camera
|
||||
|
||||
// @ts-expect-error unused variable
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
private controls: OrbitControls | null = null
|
||||
@@ -42,10 +40,9 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
_renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.eventManager = eventManager
|
||||
|
||||
this.perspectiveCamera = new THREE.PerspectiveCamera(
|
||||
|
||||
@@ -156,8 +156,9 @@ class Load3DConfiguration {
|
||||
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
} as ModelConfig
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
}
|
||||
}
|
||||
|
||||
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
||||
|
||||
@@ -727,6 +727,19 @@ class Load3d {
|
||||
return this.animationManager.animationClips.length > 0
|
||||
}
|
||||
|
||||
public hasSkeleton(): boolean {
|
||||
return this.modelManager.hasSkeleton()
|
||||
}
|
||||
|
||||
public setShowSkeleton(show: boolean): void {
|
||||
this.modelManager.setShowSkeleton(show)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getShowSkeleton(): boolean {
|
||||
return this.modelManager.showSkeleton
|
||||
}
|
||||
|
||||
public getAnimationTime(): number {
|
||||
return this.animationManager.getAnimationTime()
|
||||
}
|
||||
|
||||
@@ -27,13 +27,11 @@ export class SceneManager implements SceneManagerInterface {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
// @ts-expect-error unused variable
|
||||
private getControls: () => OrbitControls
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
getControls: () => OrbitControls,
|
||||
_getControls: () => OrbitControls,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
@@ -41,7 +39,6 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
|
||||
this.gridHelper = new THREE.GridHelper(20, 20)
|
||||
this.gridHelper.position.set(0, 0, 0)
|
||||
|
||||
@@ -30,6 +30,8 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
originalURL: string | null = null
|
||||
appliedTexture: THREE.Texture | null = null
|
||||
textureLoader: THREE.TextureLoader
|
||||
skeletonHelper: THREE.SkeletonHelper | null = null
|
||||
showSkeleton: boolean = false
|
||||
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
@@ -414,9 +416,69 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.appliedTexture = null
|
||||
}
|
||||
|
||||
if (this.skeletonHelper) {
|
||||
this.scene.remove(this.skeletonHelper)
|
||||
this.skeletonHelper.dispose()
|
||||
this.skeletonHelper = null
|
||||
}
|
||||
this.showSkeleton = false
|
||||
|
||||
this.originalMaterials = new WeakMap()
|
||||
}
|
||||
|
||||
hasSkeleton(): boolean {
|
||||
if (!this.currentModel) return false
|
||||
let found = false
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.SkinnedMesh && child.skeleton) {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
setShowSkeleton(show: boolean): void {
|
||||
this.showSkeleton = show
|
||||
|
||||
if (show) {
|
||||
if (!this.skeletonHelper && this.currentModel) {
|
||||
let rootBone: THREE.Bone | null = null
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.Bone && !rootBone) {
|
||||
if (!(child.parent instanceof THREE.Bone)) {
|
||||
rootBone = child
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (rootBone) {
|
||||
this.skeletonHelper = new THREE.SkeletonHelper(rootBone)
|
||||
this.scene.add(this.skeletonHelper)
|
||||
} else {
|
||||
let skinnedMesh: THREE.SkinnedMesh | null = null
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.SkinnedMesh && !skinnedMesh) {
|
||||
skinnedMesh = child
|
||||
}
|
||||
})
|
||||
|
||||
if (skinnedMesh) {
|
||||
this.skeletonHelper = new THREE.SkeletonHelper(skinnedMesh)
|
||||
this.scene.add(this.skeletonHelper)
|
||||
}
|
||||
}
|
||||
} else if (this.skeletonHelper) {
|
||||
this.skeletonHelper.visible = true
|
||||
}
|
||||
} else {
|
||||
if (this.skeletonHelper) {
|
||||
this.skeletonHelper.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('skeletonVisibilityChange', show)
|
||||
}
|
||||
|
||||
addModelToScene(model: THREE.Object3D): void {
|
||||
this.currentModel = model
|
||||
model.name = 'MainModel'
|
||||
|
||||
@@ -14,16 +14,13 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
private getControls: () => OrbitControls
|
||||
private eventManager: EventManagerInterface
|
||||
// @ts-expect-error unused variable
|
||||
private renderer: THREE.WebGLRenderer
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
_renderer: THREE.WebGLRenderer,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
getControls: () => OrbitControls,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
this.eventManager = eventManager
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface SceneConfig {
|
||||
export interface ModelConfig {
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
showSkeleton: boolean
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
|
||||
@@ -5,16 +5,14 @@ import { LGraphButton, Rectangle } from '@/lib/litegraph/src/litegraph'
|
||||
describe('LGraphButton', () => {
|
||||
describe('Constructor', () => {
|
||||
it('should create a button with default options', () => {
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
|
||||
const button = new LGraphButton({})
|
||||
const button = new LGraphButton({ text: '' })
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBeUndefined()
|
||||
expect(button._last_area).toBeInstanceOf(Rectangle)
|
||||
})
|
||||
|
||||
it('should create a button with custom name', () => {
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
|
||||
const button = new LGraphButton({ name: 'test_button' })
|
||||
const button = new LGraphButton({ text: '', name: 'test_button' })
|
||||
expect(button.name).toBe('test_button')
|
||||
})
|
||||
|
||||
@@ -158,9 +156,8 @@ describe('LGraphButton', () => {
|
||||
const button = new LGraphButton({
|
||||
text: '→',
|
||||
fontSize: 20,
|
||||
// @ts-expect-error TODO: Fix after merge - color property not defined in type
|
||||
color: '#FFFFFF',
|
||||
backgroundColor: '#333333',
|
||||
fgColor: '#FFFFFF',
|
||||
bgColor: '#333333',
|
||||
xOffset: -10,
|
||||
yOffset: 5
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
@@ -46,8 +47,8 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphCanvas constructor type issues
|
||||
canvas = new LGraphCanvas(canvasElement, null, {
|
||||
const graph = new LGraph()
|
||||
canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true,
|
||||
skip_events: true
|
||||
})
|
||||
@@ -56,18 +57,9 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
node.pos = [100, 200]
|
||||
node.size = [200, 100]
|
||||
|
||||
// Mock required methods
|
||||
node.drawTitleBarBackground = vi.fn()
|
||||
// @ts-expect-error Property 'drawTitleBarText' does not exist on type 'LGraphNode'
|
||||
node.drawTitleBarText = vi.fn()
|
||||
node.drawBadges = vi.fn()
|
||||
// @ts-expect-error TODO: Fix after merge - drawToggles not defined in type
|
||||
node.drawToggles = vi.fn()
|
||||
// @ts-expect-error TODO: Fix after merge - drawNodeShape not defined in type
|
||||
node.drawNodeShape = vi.fn()
|
||||
node.drawSlots = vi.fn()
|
||||
// @ts-expect-error TODO: Fix after merge - drawContent not defined in type
|
||||
node.drawContent = vi.fn()
|
||||
node.drawWidgets = vi.fn()
|
||||
node.drawCollapsedSlots = vi.fn()
|
||||
node.drawTitleBox = vi.fn()
|
||||
@@ -75,24 +67,31 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
node.drawProgressBar = vi.fn()
|
||||
node._setConcreteSlots = vi.fn()
|
||||
node.arrange = vi.fn()
|
||||
// @ts-expect-error TODO: Fix after merge - isSelectable not defined in type
|
||||
node.isSelectable = vi.fn().mockReturnValue(true)
|
||||
|
||||
const nodeWithMocks = node as LGraphNode & {
|
||||
drawTitleBarText: ReturnType<typeof vi.fn>
|
||||
drawToggles: ReturnType<typeof vi.fn>
|
||||
drawNodeShape: ReturnType<typeof vi.fn>
|
||||
drawContent: ReturnType<typeof vi.fn>
|
||||
isSelectable: ReturnType<typeof vi.fn>
|
||||
}
|
||||
nodeWithMocks.drawTitleBarText = vi.fn()
|
||||
nodeWithMocks.drawToggles = vi.fn()
|
||||
nodeWithMocks.drawNodeShape = vi.fn()
|
||||
nodeWithMocks.drawContent = vi.fn()
|
||||
nodeWithMocks.isSelectable = vi.fn().mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('drawNode title button rendering', () => {
|
||||
it('should render visible title buttons', () => {
|
||||
const button1 = node.addTitleButton({
|
||||
name: 'button1',
|
||||
text: 'A',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'A'
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: 'button2',
|
||||
text: 'B',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'B'
|
||||
})
|
||||
|
||||
// Mock button methods
|
||||
@@ -127,9 +126,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
it('should skip invisible title buttons', () => {
|
||||
const visibleButton = node.addTitleButton({
|
||||
name: 'visible',
|
||||
text: 'V',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'V'
|
||||
})
|
||||
|
||||
const invisibleButton = node.addTitleButton({
|
||||
@@ -171,9 +168,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const button = node.addTitleButton({
|
||||
name: `button${i}`,
|
||||
text: String(i),
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: String(i)
|
||||
})
|
||||
button.getWidth = vi.fn().mockReturnValue(15) // All same width for simplicity
|
||||
const spy = vi.spyOn(button, 'draw')
|
||||
@@ -196,18 +191,12 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
it('should render buttons in low quality mode', () => {
|
||||
const button = node.addTitleButton({
|
||||
name: 'test',
|
||||
text: 'T',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'T'
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
const drawSpy = vi.spyOn(button, 'draw')
|
||||
|
||||
// Set low quality rendering
|
||||
// @ts-expect-error TODO: Fix after merge - lowQualityRenderingRequired not defined in type
|
||||
canvas.lowQualityRenderingRequired = true
|
||||
|
||||
canvas.drawNode(node, ctx)
|
||||
|
||||
// Buttons should still be rendered in low quality mode
|
||||
@@ -219,16 +208,12 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
it('should handle buttons with different widths', () => {
|
||||
const smallButton = node.addTitleButton({
|
||||
name: 'small',
|
||||
text: 'S',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'S'
|
||||
})
|
||||
|
||||
const largeButton = node.addTitleButton({
|
||||
name: 'large',
|
||||
text: 'LARGE',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'LARGE'
|
||||
})
|
||||
|
||||
smallButton.getWidth = vi.fn().mockReturnValue(15)
|
||||
@@ -256,9 +241,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'test',
|
||||
text: 'X',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'X'
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
|
||||
@@ -969,10 +969,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onGroupAdd(
|
||||
// @ts-expect-error - unused parameter
|
||||
info: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
entry: unknown,
|
||||
_info: unknown,
|
||||
_entry: unknown,
|
||||
mouse_event: MouseEvent
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
@@ -1020,10 +1018,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onNodeAlign(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
@@ -1046,10 +1042,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onGroupAlign(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>
|
||||
): void {
|
||||
@@ -1070,10 +1064,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static createDistributeMenu(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>
|
||||
): void {
|
||||
@@ -1095,16 +1087,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuAdd(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
_value: unknown,
|
||||
_options: unknown,
|
||||
e: MouseEvent,
|
||||
prev_menu?: ContextMenu<string>,
|
||||
callback?: (node: LGraphNode | null) => void
|
||||
): boolean | undefined {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
const { graph } = canvas
|
||||
if (!graph) return
|
||||
|
||||
@@ -1155,14 +1144,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
value: category_path,
|
||||
content: name,
|
||||
has_submenu: true,
|
||||
callback: function (
|
||||
value,
|
||||
// @ts-expect-error - unused parameter
|
||||
event,
|
||||
// @ts-expect-error - unused parameter
|
||||
mouseEvent,
|
||||
contextMenu
|
||||
) {
|
||||
callback: function (value, _event, _mouseEvent, contextMenu) {
|
||||
inner_onMenuAdded(value.value, contextMenu)
|
||||
}
|
||||
})
|
||||
@@ -1181,14 +1163,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
value: node.type,
|
||||
content: node.title,
|
||||
has_submenu: false,
|
||||
callback: function (
|
||||
value,
|
||||
// @ts-expect-error - unused parameter
|
||||
event,
|
||||
// @ts-expect-error - unused parameter
|
||||
mouseEvent,
|
||||
contextMenu
|
||||
) {
|
||||
callback: function (value, _event, _mouseEvent, contextMenu) {
|
||||
if (!canvas.graph) throw new NullGraphError()
|
||||
|
||||
const first_event = contextMenu.getFirstEvent()
|
||||
@@ -1213,12 +1188,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(
|
||||
entries,
|
||||
{ event: e, parentMenu: prev_menu },
|
||||
// @ts-expect-error - extra parameter
|
||||
ref_window
|
||||
)
|
||||
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1227,8 +1197,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
/** @param _options Parameter is never used */
|
||||
static showMenuNodeOptionalOutputs(
|
||||
// @ts-expect-error - unused parameter
|
||||
v: unknown,
|
||||
_v: unknown,
|
||||
/** Unused - immediately overwritten */
|
||||
_options: INodeOutputSlot[],
|
||||
e: MouseEvent,
|
||||
@@ -1312,8 +1281,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** @param value Parameter is never used */
|
||||
static onShowMenuNodeProperties(
|
||||
value: NodeProperty | undefined,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
_options: unknown,
|
||||
e: MouseEvent,
|
||||
prev_menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
@@ -1321,7 +1289,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!node || !node.properties) return
|
||||
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
|
||||
const entries: IContextMenuValue<string>[] = []
|
||||
for (const i in node.properties) {
|
||||
@@ -1344,23 +1311,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu<string>(
|
||||
entries,
|
||||
{
|
||||
event: e,
|
||||
callback: inner_clicked,
|
||||
parentMenu: prev_menu,
|
||||
allow_html: true,
|
||||
node
|
||||
},
|
||||
// @ts-expect-error Unused
|
||||
ref_window
|
||||
)
|
||||
new LiteGraph.ContextMenu<string>(entries, {
|
||||
event: e,
|
||||
callback: inner_clicked,
|
||||
parentMenu: prev_menu,
|
||||
node
|
||||
})
|
||||
|
||||
function inner_clicked(this: ContextMenuDivElement, v: { value: any }) {
|
||||
if (!node) return
|
||||
function inner_clicked(
|
||||
this: ContextMenu<string>,
|
||||
v?: string | IContextMenuValue<string>
|
||||
) {
|
||||
if (!node || typeof v === 'string' || !v?.value) return
|
||||
|
||||
const rect = this.getBoundingClientRect()
|
||||
const rect = this.root.getBoundingClientRect()
|
||||
canvas.showEditPropertyValue(node, v.value, {
|
||||
position: [rect.left, rect.top]
|
||||
})
|
||||
@@ -1377,14 +1341,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuResizeNode(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node) return
|
||||
@@ -1411,11 +1371,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// TODO refactor :: this is used fot title but not for properties!
|
||||
static onShowPropertyEditor(
|
||||
item: { property: keyof LGraphNode; type: string },
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions<string>,
|
||||
_options: IContextMenuOptions<string>,
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu<string>,
|
||||
_menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
const property = item.property || 'title'
|
||||
@@ -1485,11 +1443,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
input.focus()
|
||||
|
||||
let dialogCloseTimer: number
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
dialog.addEventListener('mouseleave', function () {
|
||||
if (LiteGraph.dialog_close_on_mouse_leave) {
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -1544,14 +1501,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeCollapse(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
@@ -1578,14 +1531,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuToggleAdvanced(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
@@ -1610,10 +1559,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeMode(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
e: MouseEvent,
|
||||
menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
@@ -1657,8 +1604,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** @param value Parameter is never used */
|
||||
static onMenuNodeColors(
|
||||
value: IContextMenuValue<string | null>,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_options: IContextMenuOptions,
|
||||
e: MouseEvent,
|
||||
menu: ContextMenu<string | null>,
|
||||
node: LGraphNode
|
||||
@@ -1719,10 +1665,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeShapes(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
_value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
_options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
e: MouseEvent,
|
||||
menu?: ContextMenu<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
node?: LGraphNode
|
||||
@@ -3596,13 +3540,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.node_over?.onMouseUp?.(
|
||||
e,
|
||||
[x - this.node_over.pos[0], y - this.node_over.pos[1]],
|
||||
// @ts-expect-error - extra parameter
|
||||
this
|
||||
)
|
||||
this.node_capturing_input?.onMouseUp?.(e, [
|
||||
x - this.node_capturing_input.pos[0],
|
||||
y - this.node_capturing_input.pos[1]
|
||||
])
|
||||
this.node_capturing_input?.onMouseUp?.(
|
||||
e,
|
||||
[
|
||||
x - this.node_capturing_input.pos[0],
|
||||
y - this.node_capturing_input.pos[1]
|
||||
],
|
||||
this
|
||||
)
|
||||
}
|
||||
} else if (e.button === 1) {
|
||||
// middle button
|
||||
@@ -4599,9 +4546,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/**
|
||||
* converts a coordinate from graph coordinates to canvas2D coordinates
|
||||
*/
|
||||
convertOffsetToCanvas(pos: Point, out: Point): Point {
|
||||
// @ts-expect-error Unused param
|
||||
return this.ds.convertOffsetToCanvas(pos, out)
|
||||
convertOffsetToCanvas(pos: Point, _out?: Point): Point {
|
||||
return this.ds.convertOffsetToCanvas(pos)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6144,11 +6090,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/**
|
||||
* draws every group area in the background
|
||||
*/
|
||||
drawGroups(
|
||||
// @ts-expect-error - unused parameter
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D
|
||||
): void {
|
||||
drawGroups(_canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
|
||||
if (!this.graph) return
|
||||
|
||||
const groups = this.graph._groups
|
||||
@@ -6242,8 +6184,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
function inner_clicked(
|
||||
this: LGraphCanvas,
|
||||
v: string,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
_options: unknown,
|
||||
e: MouseEvent
|
||||
) {
|
||||
if (!graph) throw new NullGraphError()
|
||||
@@ -6762,13 +6703,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (this.ds.scale > 1) dialog.style.transform = `scale(${this.ds.scale})`
|
||||
|
||||
let dialogCloseTimer: number
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let prevent_timeout = 0
|
||||
LiteGraph.pointerListenerAdd(dialog, 'leave', function () {
|
||||
if (prevent_timeout) return
|
||||
if (LiteGraph.dialog_close_on_mouse_leave) {
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -6957,7 +6897,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (options.hide_on_mouse_leave) {
|
||||
// FIXME: Remove "any" kludge
|
||||
let prevent_timeout: any = false
|
||||
let timeout_close: number | null = null
|
||||
let timeout_close: ReturnType<typeof setTimeout> | null = null
|
||||
LiteGraph.pointerListenerAdd(dialog, 'enter', function () {
|
||||
if (timeout_close) {
|
||||
clearTimeout(timeout_close)
|
||||
@@ -6969,7 +6909,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
const hideDelay = options.hide_on_mouse_leave
|
||||
const delay = typeof hideDelay === 'number' ? hideDelay : 500
|
||||
// @ts-expect-error - setTimeout type
|
||||
timeout_close = setTimeout(dialog.close, delay)
|
||||
})
|
||||
// if filtering, check focus changed to comboboxes and prevent closing
|
||||
@@ -7005,7 +6944,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
that.search_box = dialog
|
||||
|
||||
let first: string | null = null
|
||||
let timeout: number | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
let selected: ChildNode | null = null
|
||||
|
||||
const maybeInput = dialog.querySelector('input')
|
||||
@@ -7039,7 +6978,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (timeout) {
|
||||
clearInterval(timeout)
|
||||
}
|
||||
// @ts-expect-error - setTimeout type
|
||||
timeout = setTimeout(refreshHelper, 10)
|
||||
return
|
||||
}
|
||||
@@ -7314,9 +7252,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
options.show_general_after_typefiltered &&
|
||||
(sIn.value || sOut.value)
|
||||
) {
|
||||
// FIXME: Undeclared variable again
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra = []
|
||||
const filtered_extra: string[] = []
|
||||
for (const i in LiteGraph.registered_node_types) {
|
||||
if (
|
||||
inner_test_filter(i, {
|
||||
@@ -7324,11 +7260,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
outTypeOverride: sOut && sOut.value ? '*' : false
|
||||
})
|
||||
) {
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra.push(i)
|
||||
}
|
||||
}
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
for (const extraItem of filtered_extra) {
|
||||
addResult(extraItem, 'generic_type')
|
||||
if (
|
||||
@@ -7345,14 +7279,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
helper.childNodes.length == 0 &&
|
||||
options.show_general_if_none_on_typefilter
|
||||
) {
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra = []
|
||||
const filtered_extra: string[] = []
|
||||
for (const i in LiteGraph.registered_node_types) {
|
||||
if (inner_test_filter(i, { skipFilter: true }))
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra.push(i)
|
||||
}
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
for (const extraItem of filtered_extra) {
|
||||
addResult(extraItem, 'not_in_filter')
|
||||
if (
|
||||
@@ -7647,13 +7578,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
let dialogCloseTimer: number
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let prevent_timeout = 0
|
||||
dialog.addEventListener('mouseleave', function () {
|
||||
if (prevent_timeout) return
|
||||
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -7687,7 +7617,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
createPanel(title: string, options: ICreatePanelOptions) {
|
||||
options = options || {}
|
||||
|
||||
const ref_window = options.window || window
|
||||
// TODO: any kludge
|
||||
const root: any = document.createElement('div')
|
||||
root.className = 'litegraph dialog'
|
||||
@@ -7865,16 +7794,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
innerChange(propname, v)
|
||||
return false
|
||||
}
|
||||
new LiteGraph.ContextMenu(
|
||||
values,
|
||||
{
|
||||
event,
|
||||
className: 'dark',
|
||||
callback: inner_clicked
|
||||
},
|
||||
// @ts-expect-error ref_window parameter unused in ContextMenu constructor
|
||||
ref_window
|
||||
)
|
||||
new LiteGraph.ContextMenu(values, {
|
||||
event,
|
||||
className: 'dark',
|
||||
// @ts-expect-error fixme ts strict error - callback signature mismatch
|
||||
callback: inner_clicked
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8194,14 +8119,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
{
|
||||
content: 'Properties Panel',
|
||||
callback: function (
|
||||
// @ts-expect-error - unused parameter
|
||||
item: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: any,
|
||||
_item: any,
|
||||
_options: any,
|
||||
_e: any,
|
||||
_menu: any,
|
||||
node: LGraphNode
|
||||
) {
|
||||
LGraphCanvas.active_canvas.showShowNodePanel(node)
|
||||
@@ -8312,9 +8233,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
node: LGraphNode | undefined,
|
||||
event: CanvasPointerEvent
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
|
||||
// TODO: Remove type kludge
|
||||
let menu_info: (IContextMenuValue | string | null)[]
|
||||
const options: IContextMenuOptions = {
|
||||
@@ -8428,8 +8346,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// show menu
|
||||
if (!menu_info) return
|
||||
|
||||
// @ts-expect-error Remove param ref_window - unused
|
||||
new LiteGraph.ContextMenu(menu_info, options, ref_window)
|
||||
new LiteGraph.ContextMenu(menu_info, options)
|
||||
|
||||
const createDialog = (options: IDialogOptions) =>
|
||||
this.createDialog(
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('LGraphNode', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
origLiteGraph = Object.assign({}, LiteGraph)
|
||||
// @ts-expect-error TODO: Fix after merge - Classes property not in type
|
||||
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
|
||||
delete origLiteGraph.Classes
|
||||
|
||||
Object.assign(LiteGraph, {
|
||||
|
||||
@@ -35,11 +35,10 @@ describe('LGraphNode Title Buttons', () => {
|
||||
expect(node.title_buttons[2]).toBe(button3)
|
||||
})
|
||||
|
||||
it('should create buttons with default options', () => {
|
||||
it('should create buttons with minimal options', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - addTitleButton type issues
|
||||
const button = node.addTitleButton({})
|
||||
const button = node.addTitleButton({ text: '' })
|
||||
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBeUndefined()
|
||||
@@ -55,9 +54,7 @@ describe('LGraphNode Title Buttons', () => {
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'close_button',
|
||||
text: 'X',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
text: 'X'
|
||||
})
|
||||
|
||||
// Mock button methods
|
||||
@@ -112,9 +109,7 @@ describe('LGraphNode Title Buttons', () => {
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'test_button',
|
||||
text: 'T',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
text: 'T'
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
@@ -164,16 +159,12 @@ describe('LGraphNode Title Buttons', () => {
|
||||
|
||||
const button1 = node.addTitleButton({
|
||||
name: 'button1',
|
||||
text: 'A',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
text: 'A'
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: 'button2',
|
||||
text: 'B',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
text: 'B'
|
||||
})
|
||||
|
||||
// Mock button methods
|
||||
@@ -297,8 +288,7 @@ describe('LGraphNode Title Buttons', () => {
|
||||
describe('onTitleButtonClick', () => {
|
||||
it('should dispatch litegraph:node-title-button-clicked event', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
|
||||
const button = new LGraphButton({ name: 'test_button' })
|
||||
const button = new LGraphButton({ name: 'test_button', text: 'X' })
|
||||
|
||||
const canvas = {
|
||||
dispatch: vi.fn()
|
||||
|
||||
@@ -679,7 +679,12 @@ export class LGraphNode
|
||||
this: LGraphNode,
|
||||
entries: (IContextMenuValue<INodeSlotContextItem> | null)[]
|
||||
): (IContextMenuValue<INodeSlotContextItem> | null)[]
|
||||
onMouseUp?(this: LGraphNode, e: CanvasPointerEvent, pos: Point): void
|
||||
onMouseUp?(
|
||||
this: LGraphNode,
|
||||
e: CanvasPointerEvent,
|
||||
pos: Point,
|
||||
canvas: LGraphCanvas
|
||||
): void
|
||||
onMouseEnter?(this: LGraphNode, e: CanvasPointerEvent): void
|
||||
/** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */
|
||||
onMouseDown?(
|
||||
@@ -2769,8 +2774,7 @@ export class LGraphNode
|
||||
!LiteGraph.allow_multi_output_for_events
|
||||
) {
|
||||
graph.beforeChange()
|
||||
// @ts-expect-error Unused param
|
||||
this.disconnectOutput(slot, false, { doProcessChange: false })
|
||||
this.disconnectOutput(slot)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ISerialisedGraph,
|
||||
ISerialisedNode,
|
||||
SerialisableGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
@@ -19,12 +20,7 @@ export const oldSchemaGraph: ISerialisedGraph = {
|
||||
title: 'A group to test with'
|
||||
}
|
||||
],
|
||||
nodes: [
|
||||
// @ts-expect-error TODO: Fix after merge - missing required properties for test
|
||||
{
|
||||
id: 1
|
||||
}
|
||||
],
|
||||
nodes: [{ id: 1 } as Partial<ISerialisedNode> as ISerialisedNode],
|
||||
links: []
|
||||
}
|
||||
|
||||
@@ -65,11 +61,7 @@ export const basicSerialisableGraph: SerialisableGraph = {
|
||||
}
|
||||
],
|
||||
nodes: [
|
||||
// @ts-expect-error TODO: Fix after merge - missing required properties for test
|
||||
{
|
||||
id: 1,
|
||||
type: 'mustBeSet'
|
||||
}
|
||||
{ id: 1, type: 'mustBeSet' } as Partial<ISerialisedNode> as ISerialisedNode
|
||||
],
|
||||
links: []
|
||||
}
|
||||
|
||||
@@ -338,6 +338,7 @@ export interface INodeFlags {
|
||||
*/
|
||||
export interface IWidgetLocator {
|
||||
name: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface INodeInputSlot extends INodeSlot {
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
inputAsSerialisable,
|
||||
outputAsSerialisable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
const boundingRect: ReadOnlyRect = [0, 0, 10, 10]
|
||||
|
||||
describe('NodeSlot', () => {
|
||||
describe('inputAsSerialisable', () => {
|
||||
it('removes _data from serialized slot', () => {
|
||||
// @ts-expect-error Missing boundingRect property for test
|
||||
const slot: INodeOutputSlot = {
|
||||
_data: 'test data',
|
||||
name: 'test-id',
|
||||
type: 'STRING',
|
||||
links: []
|
||||
links: [],
|
||||
boundingRect
|
||||
}
|
||||
// @ts-expect-error Argument type mismatch for test
|
||||
const serialized = outputAsSerialisable(slot)
|
||||
@@ -25,20 +28,14 @@ describe('NodeSlot', () => {
|
||||
})
|
||||
|
||||
it('removes pos from widget input slots', () => {
|
||||
// Minimal slot for serialization test - boundingRect is calculated at runtime, not serialized
|
||||
const widgetInputSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
pos: [10, 20],
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
widget: {
|
||||
name: 'test-widget',
|
||||
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
|
||||
type: 'combo',
|
||||
value: 'test-value-1',
|
||||
options: {
|
||||
values: ['test-value-1', 'test-value-2']
|
||||
}
|
||||
}
|
||||
widget: { name: 'test-widget', type: 'combo' },
|
||||
boundingRect
|
||||
}
|
||||
|
||||
const serialized = inputAsSerialisable(widgetInputSlot)
|
||||
@@ -46,30 +43,27 @@ describe('NodeSlot', () => {
|
||||
})
|
||||
|
||||
it('preserves pos for non-widget input slots', () => {
|
||||
// @ts-expect-error TODO: Fix after merge - missing boundingRect property for test
|
||||
const normalSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
type: 'STRING',
|
||||
pos: [10, 20],
|
||||
link: null
|
||||
link: null,
|
||||
boundingRect
|
||||
}
|
||||
const serialized = inputAsSerialisable(normalSlot)
|
||||
expect(serialized).toHaveProperty('pos')
|
||||
})
|
||||
|
||||
it('preserves only widget name during serialization', () => {
|
||||
// Extra widget properties simulate real data that should be stripped during serialization
|
||||
const widgetInputSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
boundingRect,
|
||||
widget: {
|
||||
name: 'test-widget',
|
||||
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
|
||||
type: 'combo',
|
||||
value: 'test-value-1',
|
||||
options: {
|
||||
values: ['test-value-1', 'test-value-2']
|
||||
}
|
||||
type: 'combo'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,8 @@ describe.skip('ExecutableNodeDTO Creation', () => {
|
||||
|
||||
expect(dto.applyToGraph).toBeDefined()
|
||||
|
||||
// Test that wrapper calls original method
|
||||
const args = ['arg1', 'arg2']
|
||||
// @ts-expect-error TODO: Fix after merge - applyToGraph expects different arguments
|
||||
dto.applyToGraph!(args[0], args[1])
|
||||
;(dto.applyToGraph as (...args: unknown[]) => void)(args[0], args[1])
|
||||
|
||||
expect(mockApplyToGraph).toHaveBeenCalledWith(args[0], args[1])
|
||||
})
|
||||
|
||||
@@ -185,14 +185,10 @@ describe.skip('Subgraph Serialization', () => {
|
||||
|
||||
expect(serialized.inputs).toHaveLength(1)
|
||||
expect(serialized.outputs).toHaveLength(1)
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.inputs possibly undefined
|
||||
expect(serialized.inputs[0].name).toBe('input')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.inputs possibly undefined
|
||||
expect(serialized.inputs[0].type).toBe('number')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.outputs possibly undefined
|
||||
expect(serialized.outputs[0].name).toBe('output')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.outputs possibly undefined
|
||||
expect(serialized.outputs[0].type).toBe('number')
|
||||
expect(serialized.inputs![0].name).toBe('input')
|
||||
expect(serialized.inputs![0].type).toBe('number')
|
||||
expect(serialized.outputs![0].name).toBe('output')
|
||||
expect(serialized.outputs![0].type).toBe('number')
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -350,8 +350,7 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Simulate concurrent operations
|
||||
// @ts-expect-error TODO: Fix after merge - operations implicitly has any[] type
|
||||
const operations = []
|
||||
const operations: Array<() => void> = []
|
||||
for (let i = 0; i < 20; i++) {
|
||||
operations.push(
|
||||
() => {
|
||||
@@ -371,7 +370,6 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
|
||||
|
||||
// Execute all operations - should not crash
|
||||
expect(() => {
|
||||
// @ts-expect-error TODO: Fix after merge - operations implicitly has any[] type
|
||||
for (const op of operations) op()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(addedEvents[0].detail.input).toBe(input)
|
||||
}
|
||||
)
|
||||
@@ -44,7 +43,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(addedEvents[0].detail.output).toBe(output)
|
||||
}
|
||||
)
|
||||
@@ -71,7 +69,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
index: 0
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(removingEvents[0].detail.input).toBe(input)
|
||||
}
|
||||
)
|
||||
@@ -98,7 +95,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
index: 0
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(removingEvents[0].detail.output).toBe(output)
|
||||
}
|
||||
)
|
||||
@@ -126,7 +122,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
newName: 'new_name'
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(renamingEvents[0].detail.input).toBe(input)
|
||||
|
||||
// Verify the label was updated after the event (renameInput sets label, not name)
|
||||
@@ -160,7 +155,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
newName: 'new_name'
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(renamingEvents[0].detail.output).toBe(output)
|
||||
|
||||
// Verify the label was updated after the event
|
||||
|
||||
@@ -28,8 +28,7 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
|
||||
}).not.toThrow()
|
||||
|
||||
expect(
|
||||
// @ts-expect-error TODO: Fix after merge - link can be null
|
||||
externalNode.outputs[0].links?.includes(subgraphNode.inputs[0].link)
|
||||
externalNode.outputs[0].links?.includes(subgraphNode.inputs[0].link!)
|
||||
).toBe(true)
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
}
|
||||
@@ -47,10 +46,8 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
|
||||
expect(simpleSubgraph.inputs.length).toBe(initialInputCount + 1)
|
||||
|
||||
// The empty slot should be configurable
|
||||
const emptyInput = simpleSubgraph.inputs.at(-1)
|
||||
// @ts-expect-error TODO: Fix after merge - emptyInput possibly undefined
|
||||
const emptyInput = simpleSubgraph.inputs.at(-1)!
|
||||
expect(emptyInput.name).toBe('')
|
||||
// @ts-expect-error TODO: Fix after merge - emptyInput possibly undefined
|
||||
expect(emptyInput.type).toBe('*')
|
||||
}
|
||||
)
|
||||
@@ -149,8 +146,7 @@ describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
|
||||
}).not.toThrow()
|
||||
|
||||
expect(
|
||||
// @ts-expect-error TODO: Fix after merge - link can be null
|
||||
subgraphNode.outputs[0].links?.includes(externalNode.inputs[0].link)
|
||||
subgraphNode.outputs[0].links?.includes(externalNode.inputs[0].link!)
|
||||
).toBe(true)
|
||||
expect(externalNode.inputs[0].link).not.toBe(null)
|
||||
}
|
||||
@@ -168,10 +164,8 @@ describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
|
||||
expect(simpleSubgraph.outputs.length).toBe(initialOutputCount + 1)
|
||||
|
||||
// The empty slot should be configurable
|
||||
const emptyOutput = simpleSubgraph.outputs.at(-1)
|
||||
// @ts-expect-error TODO: Fix after merge - emptyOutput possibly undefined
|
||||
const emptyOutput = simpleSubgraph.outputs.at(-1)!
|
||||
expect(emptyOutput.name).toBe('')
|
||||
// @ts-expect-error TODO: Fix after merge - emptyOutput possibly undefined
|
||||
expect(emptyOutput.type).toBe('*')
|
||||
}
|
||||
)
|
||||
@@ -454,15 +448,11 @@ describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
|
||||
// 3. A link should be established inside the subgraph
|
||||
expect(internalNode.inputs[0].link).not.toBe(null)
|
||||
const link = subgraph.links.get(internalNode.inputs[0].link!)
|
||||
const link = subgraph.links.get(internalNode.inputs[0].link!)!
|
||||
expect(link).toBeDefined()
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.target_id).toBe(internalNode.id)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.target_slot).toBe(0)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.origin_id).toBe(subgraph.inputNode.id)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.origin_slot).toBe(1) // Should be the second slot
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -72,10 +73,10 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,12 +94,13 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Simulate widget promotion scenario
|
||||
const input = subgraphNode.inputs[0]
|
||||
const mockWidget = {
|
||||
type: 'number',
|
||||
name: 'promoted_widget',
|
||||
value: 123,
|
||||
options: {},
|
||||
y: 0,
|
||||
draw: vi.fn(),
|
||||
mouse: vi.fn(),
|
||||
computeSize: vi.fn(),
|
||||
@@ -107,21 +109,16 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
name: 'promoted_widget',
|
||||
value: 123
|
||||
})
|
||||
}
|
||||
} as Partial<IWidget> as IWidget
|
||||
|
||||
// Simulate widget promotion
|
||||
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
|
||||
input._widget = mockWidget
|
||||
input.widget = { name: 'promoted_widget' }
|
||||
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
|
||||
subgraphNode.widgets.push(mockWidget)
|
||||
|
||||
expect(input._widget).toBe(mockWidget)
|
||||
expect(input.widget).toBeDefined()
|
||||
expect(subgraphNode.widgets).toContain(mockWidget)
|
||||
|
||||
// Remove widget (this should clean up references)
|
||||
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
|
||||
subgraphNode.removeWidget(mockWidget)
|
||||
|
||||
// Widget should be removed from array
|
||||
@@ -146,10 +143,10 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -328,17 +325,26 @@ describe.skip('SubgraphMemory - Widget Reference Management', () => {
|
||||
|
||||
const initialWidgetCount = subgraphNode.widgets?.length || 0
|
||||
|
||||
// Add mock widgets
|
||||
const widget1 = { type: 'number', value: 1, name: 'widget1' }
|
||||
const widget2 = { type: 'string', value: 'test', name: 'widget2' }
|
||||
const widget1 = {
|
||||
type: 'number',
|
||||
value: 1,
|
||||
name: 'widget1',
|
||||
options: {},
|
||||
y: 0
|
||||
} as Partial<IWidget> as IWidget
|
||||
const widget2 = {
|
||||
type: 'string',
|
||||
value: 'test',
|
||||
name: 'widget2',
|
||||
options: {},
|
||||
y: 0
|
||||
} as Partial<IWidget> as IWidget
|
||||
|
||||
if (subgraphNode.widgets) {
|
||||
// @ts-expect-error TODO: Fix after merge - widget type mismatch
|
||||
subgraphNode.widgets.push(widget1, widget2)
|
||||
expect(subgraphNode.widgets.length).toBe(initialWidgetCount + 2)
|
||||
}
|
||||
|
||||
// Remove widgets
|
||||
if (subgraphNode.widgets) {
|
||||
subgraphNode.widgets.length = initialWidgetCount
|
||||
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
@@ -210,10 +211,10 @@ describe.skip('SubgraphNode Lifecycle', () => {
|
||||
size: [180, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
|
||||
// Should reflect updated subgraph structure
|
||||
@@ -542,8 +543,6 @@ describe.skip('SubgraphNode Cleanup', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// Add and remove nodes multiple times
|
||||
// @ts-expect-error TODO: Fix after merge - SubgraphNode should be Subgraph
|
||||
const removedNodes: SubgraphNode[] = []
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const node = createTestSubgraphNode(subgraph)
|
||||
|
||||
@@ -134,9 +134,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
|
||||
// Check event was fired
|
||||
const promotedEvents = eventCapture.getEventsByType('widget-promoted')
|
||||
expect(promotedEvents).toHaveLength(1)
|
||||
// @ts-expect-error Object is of type 'unknown'
|
||||
expect(promotedEvents[0].detail.widget).toBeDefined()
|
||||
// @ts-expect-error Object is of type 'unknown'
|
||||
expect(promotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
|
||||
|
||||
eventCapture.cleanup()
|
||||
@@ -161,9 +159,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
|
||||
// Check event was fired
|
||||
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
|
||||
expect(demotedEvents).toHaveLength(1)
|
||||
// @ts-expect-error Object is of type 'unknown'
|
||||
expect(demotedEvents[0].detail.widget).toBeDefined()
|
||||
// @ts-expect-error Object is of type 'unknown'
|
||||
expect(demotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
|
||||
|
||||
// Widget should be removed
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphEventMap'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
import { test } from '../../__fixtures__/testExtensions'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './subgraphHelpers'
|
||||
import type { EventCapture } from './subgraphHelpers'
|
||||
|
||||
interface SubgraphFixtures {
|
||||
/** A minimal subgraph with no inputs, outputs, or nodes */
|
||||
@@ -41,7 +43,7 @@ interface SubgraphFixtures {
|
||||
/** Event capture system for testing subgraph events */
|
||||
eventCapture: {
|
||||
subgraph: Subgraph
|
||||
capture: ReturnType<typeof createEventCapture>
|
||||
capture: EventCapture<SubgraphEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +61,7 @@ interface SubgraphFixtures {
|
||||
* ```
|
||||
*/
|
||||
export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
emptySubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
emptySubgraph: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Empty Test Subgraph',
|
||||
inputCount: 0,
|
||||
@@ -72,9 +72,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
await use(subgraph)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
simpleSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
simpleSubgraph: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Simple Test Subgraph',
|
||||
inputs: [{ name: 'input', type: 'number' }],
|
||||
@@ -85,9 +83,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
await use(subgraph)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
complexSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
complexSubgraph: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Complex Test Subgraph',
|
||||
inputs: [
|
||||
@@ -105,9 +101,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
await use(subgraph)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
nestedSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
nestedSubgraph: async ({}, use) => {
|
||||
const nested = createNestedSubgraphs({
|
||||
depth: 3,
|
||||
nodesPerLevel: 2,
|
||||
@@ -118,10 +112,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
await use(nested)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
subgraphWithNode: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
// Create the subgraph definition
|
||||
subgraphWithNode: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Subgraph With Node',
|
||||
inputs: [{ name: 'input', type: '*' }],
|
||||
@@ -129,14 +120,12 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
nodeCount: 1
|
||||
})
|
||||
|
||||
// Create the parent graph and subgraph node instance
|
||||
const parentGraph = new LGraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
pos: [200, 200],
|
||||
size: [180, 80]
|
||||
})
|
||||
|
||||
// Add the subgraph node to the parent graph
|
||||
parentGraph.add(subgraphNode)
|
||||
|
||||
await use({
|
||||
@@ -146,15 +135,12 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
})
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
eventCapture: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
eventCapture: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Event Test Subgraph'
|
||||
})
|
||||
|
||||
// Set up event capture for all subgraph events
|
||||
const capture = createEventCapture(subgraph.events, [
|
||||
const capture = createEventCapture<SubgraphEventMap>(subgraph.events, [
|
||||
'adding-input',
|
||||
'input-added',
|
||||
'removing-input',
|
||||
@@ -167,7 +153,6 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
|
||||
await use({ subgraph, capture })
|
||||
|
||||
// Cleanup event listeners
|
||||
capture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -55,6 +55,16 @@ interface CapturedEvent<T = unknown> {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** Return type for createEventCapture with typed getEventsByType */
|
||||
export interface EventCapture<TEventMap extends object> {
|
||||
events: CapturedEvent<TEventMap[keyof TEventMap]>[]
|
||||
clear: () => void
|
||||
cleanup: () => void
|
||||
getEventsByType: <K extends keyof TEventMap & string>(
|
||||
type: K
|
||||
) => CapturedEvent<TEventMap[K]>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test subgraph with specified inputs, outputs, and nodes.
|
||||
* This is the primary function for creating subgraphs in tests.
|
||||
@@ -91,34 +101,35 @@ export function createTestSubgraph(
|
||||
}
|
||||
const rootGraph = new LGraph()
|
||||
|
||||
// Create the base subgraph data
|
||||
const subgraphData: ExportedSubgraph = {
|
||||
// Basic graph properties
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [],
|
||||
// @ts-expect-error TODO: Fix after merge - links type mismatch
|
||||
links: {},
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
definitions: { subgraphs: [] },
|
||||
|
||||
// Subgraph-specific properties
|
||||
id: options.id || createUuidv4(),
|
||||
name: options.name || 'Test Subgraph',
|
||||
|
||||
// IO Nodes (required for subgraph functionality)
|
||||
inputNode: {
|
||||
id: -10, // SUBGRAPH_INPUT_ID
|
||||
bounding: [10, 100, 150, 126], // [x, y, width, height]
|
||||
id: -10,
|
||||
bounding: [10, 100, 150, 126],
|
||||
pinned: false
|
||||
},
|
||||
outputNode: {
|
||||
id: -20, // SUBGRAPH_OUTPUT_ID
|
||||
bounding: [400, 100, 140, 126], // [x, y, width, height]
|
||||
id: -20,
|
||||
bounding: [400, 100, 140, 126],
|
||||
pinned: false
|
||||
},
|
||||
|
||||
// IO definitions - will be populated by addInput/addOutput calls
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: []
|
||||
@@ -127,11 +138,9 @@ export function createTestSubgraph(
|
||||
// Create the subgraph
|
||||
const subgraph = new Subgraph(rootGraph, subgraphData)
|
||||
|
||||
// Add requested inputs
|
||||
if (options.inputs) {
|
||||
for (const input of options.inputs) {
|
||||
// @ts-expect-error TODO: Fix after merge - addInput parameter types
|
||||
subgraph.addInput(input.name, input.type)
|
||||
subgraph.addInput(input.name, String(input.type))
|
||||
}
|
||||
} else if (options.inputCount) {
|
||||
for (let i = 0; i < options.inputCount; i++) {
|
||||
@@ -139,11 +148,9 @@ export function createTestSubgraph(
|
||||
}
|
||||
}
|
||||
|
||||
// Add requested outputs
|
||||
if (options.outputs) {
|
||||
for (const output of options.outputs) {
|
||||
// @ts-expect-error TODO: Fix after merge - addOutput parameter types
|
||||
subgraph.addOutput(output.name, output.type)
|
||||
subgraph.addOutput(output.name, String(output.type))
|
||||
}
|
||||
} else if (options.outputCount) {
|
||||
for (let i = 0; i < options.outputCount; i++) {
|
||||
@@ -193,10 +200,10 @@ export function createTestSubgraphNode(
|
||||
size: options.size || [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
// @ts-expect-error TODO: Fix after merge - properties type mismatch
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0
|
||||
mode: 0,
|
||||
order: 0
|
||||
}
|
||||
|
||||
return new SubgraphNode(parentGraph, subgraph, instanceData)
|
||||
@@ -237,18 +244,11 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
|
||||
// Create instance in parent
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
pos: [100 + level * 200, 100]
|
||||
})
|
||||
|
||||
if (currentParent instanceof LGraph) {
|
||||
currentParent.add(subgraphNode)
|
||||
} else {
|
||||
// @ts-expect-error TODO: Fix after merge - add method parameter types
|
||||
currentParent.add(subgraphNode)
|
||||
}
|
||||
|
||||
currentParent.add(subgraphNode)
|
||||
subgraphNodes.push(subgraphNode)
|
||||
|
||||
// Next level will be nested inside this subgraph
|
||||
@@ -353,9 +353,15 @@ export function createTestSubgraphData(
|
||||
): ExportedSubgraph {
|
||||
return {
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [],
|
||||
// @ts-expect-error TODO: Fix after merge - links type mismatch
|
||||
links: {},
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
definitions: { subgraphs: [] },
|
||||
@@ -386,13 +392,13 @@ export function createTestSubgraphData(
|
||||
* Creates an event capture system for testing event sequences.
|
||||
* @param eventTarget The event target to monitor
|
||||
* @param eventTypes Array of event types to capture
|
||||
* @returns Object with captured events and helper methods
|
||||
* @returns Object with captured events and typed getEventsByType method
|
||||
*/
|
||||
export function createEventCapture<T = unknown>(
|
||||
export function createEventCapture<TEventMap extends object = object>(
|
||||
eventTarget: EventTarget,
|
||||
eventTypes: string[]
|
||||
) {
|
||||
const capturedEvents: CapturedEvent<T>[] = []
|
||||
eventTypes: Array<keyof TEventMap & string>
|
||||
): EventCapture<TEventMap> {
|
||||
const capturedEvents: CapturedEvent<TEventMap[keyof TEventMap]>[] = []
|
||||
const listeners: Array<() => void> = []
|
||||
|
||||
// Set up listeners for each event type
|
||||
@@ -400,7 +406,7 @@ export function createEventCapture<T = unknown>(
|
||||
const listener = (event: Event) => {
|
||||
capturedEvents.push({
|
||||
type: eventType,
|
||||
detail: (event as CustomEvent<T>).detail,
|
||||
detail: (event as CustomEvent<TEventMap[typeof eventType]>).detail,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
@@ -418,7 +424,9 @@ export function createEventCapture<T = unknown>(
|
||||
// Remove all event listeners to prevent memory leaks
|
||||
for (const cleanup of listeners) cleanup()
|
||||
},
|
||||
getEventsByType: (type: string) =>
|
||||
capturedEvents.filter((e) => e.type === type)
|
||||
getEventsByType: <K extends keyof TEventMap & string>(type: K) =>
|
||||
capturedEvents.filter((e) => e.type === type) as CapturedEvent<
|
||||
TEventMap[K]
|
||||
>[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface ExportedSubgraphInstance extends NodeSubgraphSharedProps {
|
||||
* @see {@link ExportedSubgraph.subgraphs}
|
||||
*/
|
||||
type: UUID
|
||||
/** Custom properties for this subgraph instance */
|
||||
properties?: Dictionary<NodeProperty | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -463,7 +463,8 @@ describe('ComboWidget', () => {
|
||||
.mockImplementation(function (_values, options) {
|
||||
capturedCallback = options.callback
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
@@ -505,7 +506,8 @@ describe('ComboWidget', () => {
|
||||
.mockImplementation(function (_values, options) {
|
||||
capturedCallback = options.callback
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
@@ -766,8 +768,8 @@ describe('ComboWidget', () => {
|
||||
.mockImplementation(function () {
|
||||
this.addItem = mockAddItem
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
// Should show formatted labels in dropdown
|
||||
@@ -829,7 +831,8 @@ describe('ComboWidget', () => {
|
||||
capturedCallback = options.callback
|
||||
this.addItem = mockAddItem
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
@@ -882,7 +885,8 @@ describe('ComboWidget', () => {
|
||||
capturedCallback = options.callback
|
||||
this.addItem = mockAddItem
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
@@ -960,7 +964,8 @@ describe('ComboWidget', () => {
|
||||
.mockImplementation(function () {
|
||||
this.addItem = mockAddItem
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
@@ -1007,7 +1012,8 @@ describe('ComboWidget', () => {
|
||||
node.size = [200, 30]
|
||||
|
||||
const mockContextMenu = vi.fn<typeof LiteGraph.ContextMenu>()
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
|
||||
@@ -227,6 +227,9 @@
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "Publish Subgraph"
|
||||
},
|
||||
"Comfy_Queue_ToggleOverlay": {
|
||||
"label": "Toggle Job History"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "Queue Prompt"
|
||||
},
|
||||
@@ -263,6 +266,9 @@
|
||||
"Comfy_ToggleLinear": {
|
||||
"label": "toggle linear mode"
|
||||
},
|
||||
"Comfy_ToggleQPOV2": {
|
||||
"label": "Toggle Queue Panel V2"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "Toggle Theme (Dark/Light)"
|
||||
},
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
"missing": "Missing",
|
||||
"inProgress": "In progress",
|
||||
"completed": "Completed",
|
||||
"downloading": "Downloading",
|
||||
"interrupted": "Interrupted",
|
||||
"queued": "Queued",
|
||||
"running": "Running",
|
||||
@@ -720,6 +721,8 @@
|
||||
"colonPercent": ": {percent}",
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"viewList": "List view",
|
||||
"viewGrid": "Grid view",
|
||||
"running": "running",
|
||||
"preview": "Preview",
|
||||
"interruptAll": "Interrupt all running jobs",
|
||||
@@ -735,6 +738,7 @@
|
||||
"filterCurrentWorkflow": "Current workflow",
|
||||
"sortJobs": "Sort jobs",
|
||||
"sortBy": "Sort by",
|
||||
"activeJobs": "{count} active job | {count} active jobs",
|
||||
"activeJobsSuffix": "active jobs",
|
||||
"jobQueue": "Job Queue",
|
||||
"expandCollapsedQueue": "Expand job queue",
|
||||
@@ -873,7 +877,7 @@
|
||||
"noResultsHint": "Try adjusting your search or filters",
|
||||
"allTemplates": "All Templates",
|
||||
"modelFilter": "Model Filter",
|
||||
"useCaseFilter": "Use Case",
|
||||
"useCaseFilter": "Tasks",
|
||||
"licenseFilter": "License",
|
||||
"modelsSelected": "{count} Models",
|
||||
"useCasesSelected": "{count} Use Cases",
|
||||
@@ -882,6 +886,7 @@
|
||||
"resultsCount": "Showing {count} of {total} templates",
|
||||
"sort": {
|
||||
"recommended": "Recommended",
|
||||
"popular": "Popular",
|
||||
"alphabetical": "A → Z",
|
||||
"newest": "Newest",
|
||||
"searchPlaceholder": "Search...",
|
||||
@@ -1150,6 +1155,7 @@
|
||||
"Manager": "Manager",
|
||||
"Open": "Open",
|
||||
"Publish": "Publish",
|
||||
"Job History": "Job History",
|
||||
"Queue Prompt": "Queue Prompt",
|
||||
"Queue Prompt (Front)": "Queue Prompt (Front)",
|
||||
"Queue Selected Output Nodes": "Queue Selected Output Nodes",
|
||||
@@ -1162,6 +1168,7 @@
|
||||
"Canvas Performance": "Canvas Performance",
|
||||
"Help Center": "Help Center",
|
||||
"toggle linear mode": "toggle linear mode",
|
||||
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
|
||||
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
|
||||
"Undo": "Undo",
|
||||
"Open Sign In Dialog": "Open Sign In Dialog",
|
||||
@@ -1636,6 +1643,7 @@
|
||||
"loadingModel": "Loading 3D Model...",
|
||||
"upDirection": "Up Direction",
|
||||
"materialMode": "Material Mode",
|
||||
"showSkeleton": "Show Skeleton",
|
||||
"scene": "Scene",
|
||||
"model": "Model",
|
||||
"camera": "Camera",
|
||||
@@ -1906,7 +1914,7 @@
|
||||
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
|
||||
"creditsDescription": "Credits are used to run workflows or partner nodes.",
|
||||
"howManyCredits": "How many credits would you like to add?",
|
||||
"videosEstimate": "~{count} videos*",
|
||||
"videosEstimate": "~{count} videos",
|
||||
"templateNote": "*Generated with Wan Fun Control template",
|
||||
"buy": "Buy",
|
||||
"purchaseError": "Purchase Failed",
|
||||
@@ -2009,10 +2017,11 @@
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimateLabel": "Number of 5s videos generated with Wan Fun Control template",
|
||||
"videoEstimateHelp": "What is this?",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan Fun Control template for 5-second videos.",
|
||||
"videoEstimateTryTemplate": "Try the Wan Fun Control template →",
|
||||
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan 2.2 Image-to-Video template",
|
||||
"videoEstimateHelp": "More details on this template",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan 2.2 Image-to-Video template using default settings (5 seconds, 640x640, 16fps, 4-step sampling).",
|
||||
"videoEstimateTryTemplate": "Try this template",
|
||||
"videoTemplateBasedCredits": "Videos generated with Wan 2.2 Image to Video",
|
||||
"upgradePlan": "Upgrade Plan",
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
@@ -2285,14 +2294,16 @@
|
||||
"noAssetsFound": "No assets found",
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"noValidSourceDetected": "No valid import source detected",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"ownership": "Ownership",
|
||||
"ownershipAll": "All",
|
||||
"ownershipMyModels": "My models",
|
||||
"ownershipPublicModels": "Public models",
|
||||
"processingModel": "Download started",
|
||||
"processingModelDescription": "You can close this dialog. The download will continue in the background.",
|
||||
"providerCivitai": "Civitai",
|
||||
"providerHuggingFace": "Hugging Face",
|
||||
"noValidSourceDetected": "No valid import source detected",
|
||||
"selectFrameworks": "Select Frameworks",
|
||||
"selectModelType": "Select model type",
|
||||
"selectProjects": "Select Projects",
|
||||
@@ -2317,8 +2328,8 @@
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription1Generic": "Paste a model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from {link} are supported at the moment",
|
||||
"uploadModelDescription2Link": "https://civitai.com/models",
|
||||
"uploadModelDescription2Generic": "Only URLs from the following providers are supported:",
|
||||
"uploadModelDescription2Link": "https://civitai.com/models",
|
||||
"uploadModelDescription3": "Max file size: {size}",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"uploadModelFromCivitai": "Import a model from Civitai",
|
||||
@@ -2342,6 +2353,11 @@
|
||||
"complete": "{assetName} has been deleted.",
|
||||
"failed": "{assetName} could not be deleted."
|
||||
},
|
||||
"download": {
|
||||
"complete": "Download complete",
|
||||
"failed": "Download failed",
|
||||
"inProgress": "Downloading {assetName}..."
|
||||
},
|
||||
"rename": {
|
||||
"failed": "Could not rename asset."
|
||||
}
|
||||
|
||||
@@ -6079,6 +6079,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXAVTextEncoderLoader": {
|
||||
"display_name": "LTXV Audio Text Encoder Loader",
|
||||
"description": "[Recipes]\n\nltxav: gemma 3 12B",
|
||||
"inputs": {
|
||||
"text_encoder": {
|
||||
"name": "text_encoder"
|
||||
},
|
||||
"ckpt_name": {
|
||||
"name": "ckpt_name"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVAddGuide": {
|
||||
"display_name": "LTXVAddGuide",
|
||||
"inputs": {
|
||||
@@ -6185,6 +6202,76 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVAudioVAEDecode": {
|
||||
"display_name": "LTXV Audio VAE Decode",
|
||||
"inputs": {
|
||||
"samples": {
|
||||
"name": "samples",
|
||||
"tooltip": "The latent to be decoded."
|
||||
},
|
||||
"audio_vae": {
|
||||
"name": "audio_vae",
|
||||
"tooltip": "The Audio VAE model used for decoding the latent."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "Audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVAudioVAEEncode": {
|
||||
"display_name": "LTXV Audio VAE Encode",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "The audio to be encoded."
|
||||
},
|
||||
"audio_vae": {
|
||||
"name": "audio_vae",
|
||||
"tooltip": "The Audio VAE model to use for encoding."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "Audio Latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVAudioVAELoader": {
|
||||
"display_name": "LTXV Audio VAE Loader",
|
||||
"inputs": {
|
||||
"ckpt_name": {
|
||||
"name": "ckpt_name",
|
||||
"tooltip": "Audio VAE checkpoint to load."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "Audio VAE",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVConcatAVLatent": {
|
||||
"display_name": "LTXVConcatAVLatent",
|
||||
"inputs": {
|
||||
"video_latent": {
|
||||
"name": "video_latent"
|
||||
},
|
||||
"audio_latent": {
|
||||
"name": "audio_latent"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVConditioning": {
|
||||
"display_name": "LTXVConditioning",
|
||||
"inputs": {
|
||||
@@ -6237,6 +6324,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVEmptyLatentAudio": {
|
||||
"display_name": "LTXV Empty Latent Audio",
|
||||
"inputs": {
|
||||
"frames_number": {
|
||||
"name": "frames_number",
|
||||
"tooltip": "Number of frames."
|
||||
},
|
||||
"frame_rate": {
|
||||
"name": "frame_rate",
|
||||
"tooltip": "Number of frames per second."
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "batch_size",
|
||||
"tooltip": "The number of latent audio samples in the batch."
|
||||
},
|
||||
"audio_vae": {
|
||||
"name": "audio_vae",
|
||||
"tooltip": "The Audio VAE model to get configuration from."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "Latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVImgToVideo": {
|
||||
"display_name": "LTXVImgToVideo",
|
||||
"inputs": {
|
||||
@@ -6283,6 +6397,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVImgToVideoInplace": {
|
||||
"display_name": "LTXVImgToVideoInplace",
|
||||
"inputs": {
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"latent": {
|
||||
"name": "latent"
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength"
|
||||
},
|
||||
"bypass": {
|
||||
"name": "bypass",
|
||||
"tooltip": "Bypass the conditioning."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVLatentUpsampler": {
|
||||
"display_name": "LTXVLatentUpsampler",
|
||||
"inputs": {
|
||||
"samples": {
|
||||
"name": "samples"
|
||||
},
|
||||
"upscale_model": {
|
||||
"name": "upscale_model"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVPreprocess": {
|
||||
"display_name": "LTXVPreprocess",
|
||||
"inputs": {
|
||||
@@ -6331,6 +6486,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVSeparateAVLatent": {
|
||||
"display_name": "LTXVSeparateAVLatent",
|
||||
"description": "LTXV Separate AV Latent",
|
||||
"inputs": {
|
||||
"av_latent": {
|
||||
"name": "av_latent"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "video_latent",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "audio_latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaConceptsNode": {
|
||||
"display_name": "Luma Concepts",
|
||||
"description": "Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.",
|
||||
|
||||
@@ -80,9 +80,6 @@
|
||||
"Comfy_Canvas_ToggleMinimap": {
|
||||
"label": "画布切换小地图"
|
||||
},
|
||||
"Comfy_Canvas_ToggleSelected_Pin": {
|
||||
"label": "固定/取消固定选中项"
|
||||
},
|
||||
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
|
||||
"label": "忽略/取消忽略选中节点"
|
||||
},
|
||||
@@ -95,6 +92,9 @@
|
||||
"Comfy_Canvas_ToggleSelectedNodes_Pin": {
|
||||
"label": "固定/取消固定选中节点"
|
||||
},
|
||||
"Comfy_Canvas_ToggleSelected_Pin": {
|
||||
"label": "固定/取消固定选中项"
|
||||
},
|
||||
"Comfy_Canvas_Unlock": {
|
||||
"label": "解锁画布"
|
||||
},
|
||||
@@ -290,9 +290,6 @@
|
||||
"Workspace_ToggleBottomPanel": {
|
||||
"label": "切换底部面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "显示快捷键对话框"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_command-terminal": {
|
||||
"label": "切换终端底部面板"
|
||||
},
|
||||
@@ -305,6 +302,9 @@
|
||||
"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
@@ -83,6 +83,7 @@ import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -132,6 +133,17 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
|
||||
watch(
|
||||
() => assetDownloadStore.hasActiveDownloads,
|
||||
async (currentlyActive, previouslyActive) => {
|
||||
if (previouslyActive && !currentlyActive) {
|
||||
await execute()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
<IconGroup
|
||||
v-if="flags.assetUpdateOptionsEnabled && !(asset.is_immutable ?? true)"
|
||||
v-if="showAssetOptions"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-2 right-2 invisible group-hover:visible',
|
||||
@@ -44,6 +44,7 @@
|
||||
<MoreButton ref="dropdown-menu-button" size="sm">
|
||||
<template #default>
|
||||
<Button
|
||||
v-if="flags.assetRenameEnabled"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="justify-start"
|
||||
@@ -53,6 +54,7 @@
|
||||
<span>{{ $t('g.rename') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="flags.assetDeletionEnabled"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="justify-start"
|
||||
@@ -160,6 +162,12 @@ const deletedLocal = ref(false)
|
||||
|
||||
const displayName = computed(() => newNameRef.value ?? asset.name)
|
||||
|
||||
const showAssetOptions = computed(
|
||||
() =>
|
||||
(flags.assetDeletionEnabled || flags.assetRenameEnabled) &&
|
||||
!(asset.is_immutable ?? true)
|
||||
)
|
||||
|
||||
const tooltipDelay = computed<number>(() =>
|
||||
settingStore.get('LiteGraph.Node.TooltipDelay')
|
||||
)
|
||||
|
||||
@@ -68,9 +68,10 @@
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<!-- Output count (top-right) -->
|
||||
<template v-if="showOutputCount" #top-right>
|
||||
<!-- Output count or duration chip (top-right) -->
|
||||
<template v-if="showOutputCount || showTouchDurationChip" #top-right>
|
||||
<Button
|
||||
v-if="showOutputCount"
|
||||
v-tooltip.top.pt:pointer-events-none="
|
||||
$t('mediaAsset.actions.seeMoreOutputs')
|
||||
"
|
||||
@@ -81,6 +82,12 @@
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
<span>{{ outputCount }}</span>
|
||||
</Button>
|
||||
<!-- Duration chip on touch devices (far right) -->
|
||||
<SquareChip
|
||||
v-else-if="showTouchDurationChip"
|
||||
variant="gray"
|
||||
:label="formattedDuration"
|
||||
/>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
@@ -124,7 +131,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover, whenever } from '@vueuse/core'
|
||||
import { useElementHover, useMediaQuery, whenever } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
@@ -202,6 +209,7 @@ const showVideoControls = ref(false)
|
||||
const imageDimensions = ref<{ width: number; height: number } | undefined>()
|
||||
|
||||
const isHovered = useElementHover(cardContainerRef)
|
||||
const isTouch = useMediaQuery('(hover: none)')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
@@ -272,19 +280,28 @@ const durationChipClasses = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
// Show static chips when NOT hovered and NOT playing (normal state)
|
||||
// Show static chips when NOT hovered and NOT playing (normal state on non-touch)
|
||||
const showStaticChips = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
!!asset &&
|
||||
!isHovered.value &&
|
||||
!isVideoPlaying.value &&
|
||||
!isTouch.value &&
|
||||
formattedDuration.value
|
||||
)
|
||||
|
||||
// Show action overlay when hovered OR playing
|
||||
// Show duration chip in top-right on touch devices
|
||||
const showTouchDurationChip = computed(
|
||||
() => !loading && !!asset && isTouch.value && formattedDuration.value
|
||||
)
|
||||
|
||||
// Show action overlay when hovered, playing, or on touch device
|
||||
const showActionsOverlay = computed(
|
||||
() => !loading && !!asset && (isHovered.value || isVideoPlaying.value)
|
||||
() =>
|
||||
!loading &&
|
||||
!!asset &&
|
||||
(isHovered.value || isVideoPlaying.value || isTouch.value)
|
||||
)
|
||||
|
||||
const handleZoomClick = () => {
|
||||
|
||||
@@ -31,18 +31,26 @@
|
||||
/>
|
||||
</template>
|
||||
</AssetSortButton>
|
||||
<MediaAssetViewModeToggle
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
v-model:view-mode="viewMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
|
||||
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
|
||||
import AssetSortButton from './MediaAssetSortButton.vue'
|
||||
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
|
||||
import type { SortBy } from './MediaAssetSortMenu.vue'
|
||||
import MediaAssetViewModeToggle from './MediaAssetViewModeToggle.vue'
|
||||
|
||||
const { showGenerationTimeSort = false } = defineProps<{
|
||||
searchQuery: string
|
||||
@@ -56,6 +64,12 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
const handleSearchChange = (value: string | undefined) => {
|
||||
emit('update:searchQuery', value ?? '')
|
||||
|
||||
54
src/platform/assets/components/MediaAssetViewModeToggle.vue
Normal file
54
src/platform/assets/components/MediaAssetViewModeToggle.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-secondary-background p-1"
|
||||
role="group"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.viewList')"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg',
|
||||
viewMode === 'list'
|
||||
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
|
||||
: 'text-text-secondary hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<i class="icon-[lucide--table-of-contents] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.viewGrid')"
|
||||
:aria-pressed="viewMode === 'grid'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg',
|
||||
viewMode === 'grid'
|
||||
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
|
||||
: 'text-text-secondary hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
<UploadModelProgress
|
||||
v-else-if="currentStep === 3"
|
||||
:status="uploadStatus"
|
||||
v-else-if="currentStep === 3 && uploadStatus != null"
|
||||
:result="uploadStatus"
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
|
||||
@@ -81,12 +81,19 @@
|
||||
<span>{{ $t('assetBrowser.upload') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="currentStep === 3 && uploadStatus === 'success'"
|
||||
v-else-if="
|
||||
currentStep === 3 &&
|
||||
(uploadStatus === 'success' || uploadStatus === 'processing')
|
||||
"
|
||||
variant="secondary"
|
||||
data-attr="upload-model-step3-finish-button"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ $t('assetBrowser.finish') }}
|
||||
{{
|
||||
uploadStatus === 'processing'
|
||||
? $t('g.close')
|
||||
: $t('assetBrowser.finish')
|
||||
}}
|
||||
</Button>
|
||||
<VideoHelpDialog
|
||||
v-model="showCivitaiHelp"
|
||||
@@ -119,7 +126,7 @@ defineProps<{
|
||||
isUploading: boolean
|
||||
canFetchMetadata: boolean
|
||||
canUploadModel: boolean
|
||||
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
|
||||
uploadStatus?: 'processing' | 'success' | 'error'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
|
||||
<!-- Uploading State -->
|
||||
<div
|
||||
v-if="status === 'uploading'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-muted-foreground"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p class="m-0 font-bold">
|
||||
{{ $t('assetBrowser.uploadingModel') }}
|
||||
</p>
|
||||
<!-- Processing State (202 async download in progress) -->
|
||||
<div v-if="result === 'processing'" class="flex flex-col gap-2">
|
||||
<p class="m-0 font-bold">
|
||||
{{ $t('assetBrowser.processingModel') }}
|
||||
</p>
|
||||
<p class="m-0">
|
||||
{{ $t('assetBrowser.processingModelDescription') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
|
||||
>
|
||||
<img
|
||||
v-if="previewImage"
|
||||
:src="previewImage"
|
||||
:alt="metadata?.filename || metadata?.name || 'Model preview'"
|
||||
class="w-14 h-14 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-base-foreground m-0">
|
||||
{{ metadata?.filename || metadata?.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-2">
|
||||
<div v-else-if="result === 'success'" class="flex flex-col gap-2">
|
||||
<p class="m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }}
|
||||
</p>
|
||||
@@ -47,7 +61,7 @@
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
v-else-if="result === 'error'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] text-6xl text-error" />
|
||||
@@ -66,8 +80,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
defineProps<{
|
||||
status: 'idle' | 'uploading' | 'success' | 'error'
|
||||
const { result } = defineProps<{
|
||||
result: 'processing' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata?: AssetMetadata
|
||||
modelType?: string
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else class="text-foreground">
|
||||
<p v-else-if="!flags.asyncModelUploadEnabled" class="text-foreground">
|
||||
<i18n-t keypath="assetBrowser.maxFileSize" tag="span">
|
||||
<template #size>
|
||||
<span class="font-bold italic">{{
|
||||
@@ -77,6 +77,10 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
error?: string
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
</i18n-t>
|
||||
</li>
|
||||
<li>
|
||||
<li v-if="!flags.asyncModelUploadEnabled">
|
||||
<i18n-t keypath="assetBrowser.uploadModelDescription3" tag="span">
|
||||
<template #size>
|
||||
<span class="font-bold italic">{{
|
||||
@@ -74,6 +74,10 @@
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
defineProps<{
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { ImportSource } from '@/platform/assets/types/importSource'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
@@ -29,12 +30,13 @@ interface ModelTypeOption {
|
||||
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
const { t } = useI18n()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const currentStep = ref(1)
|
||||
const isFetchingMetadata = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
|
||||
const uploadStatus = ref<'processing' | 'success' | 'error'>()
|
||||
const uploadError = ref('')
|
||||
|
||||
const wizardData = ref<WizardData>({
|
||||
@@ -154,11 +156,59 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadModel() {
|
||||
if (!canUploadModel.value) return
|
||||
async function uploadPreviewImage(
|
||||
filename: string
|
||||
): Promise<string | undefined> {
|
||||
if (!wizardData.value.previewImage) return undefined
|
||||
|
||||
try {
|
||||
const baseFilename = filename.split('.')[0]
|
||||
let extension = 'png'
|
||||
const mimeMatch = wizardData.value.previewImage.match(
|
||||
/^data:image\/([^;]+);/
|
||||
)
|
||||
if (mimeMatch) {
|
||||
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
|
||||
}
|
||||
|
||||
const previewAsset = await assetService.uploadAssetFromBase64({
|
||||
data: wizardData.value.previewImage,
|
||||
name: `${baseFilename}_preview.${extension}`,
|
||||
tags: ['preview']
|
||||
})
|
||||
return previewAsset.id
|
||||
} catch (error) {
|
||||
console.error('Failed to upload preview image:', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModelCaches() {
|
||||
if (!selectedModelType.value) return
|
||||
|
||||
const providers = modelToNodeStore.getAllNodeProviders(
|
||||
selectedModelType.value
|
||||
)
|
||||
const results = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
|
||||
)
|
||||
)
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`Failed to refresh ${providers[index].nodeDef.name}:`,
|
||||
result.reason
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadModel(): Promise<boolean> {
|
||||
if (!canUploadModel.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Defensive check: detectedSource should be valid after fetchMetadata validation,
|
||||
// but guard against edge cases (e.g., URL modified between steps)
|
||||
const source = detectedSource.value
|
||||
if (!source) {
|
||||
uploadError.value = t('assetBrowser.noValidSourceDetected')
|
||||
@@ -166,7 +216,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
uploadStatus.value = 'uploading'
|
||||
|
||||
try {
|
||||
const tags = selectedModelType.value
|
||||
@@ -177,72 +226,56 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
wizardData.value.metadata?.name ||
|
||||
'model'
|
||||
|
||||
let previewId: string | undefined
|
||||
|
||||
// Upload preview image first if available
|
||||
if (wizardData.value.previewImage) {
|
||||
try {
|
||||
const baseFilename = filename.split('.')[0]
|
||||
|
||||
// Extract extension from data URL MIME type
|
||||
let extension = 'png'
|
||||
const mimeMatch = wizardData.value.previewImage.match(
|
||||
/^data:image\/([^;]+);/
|
||||
)
|
||||
if (mimeMatch) {
|
||||
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
|
||||
}
|
||||
|
||||
const previewAsset = await assetService.uploadAssetFromBase64({
|
||||
data: wizardData.value.previewImage,
|
||||
name: `${baseFilename}_preview.${extension}`,
|
||||
tags: ['preview']
|
||||
})
|
||||
previewId = previewAsset.id
|
||||
} catch (error) {
|
||||
console.error('Failed to upload preview image:', error)
|
||||
// Continue with model upload even if preview fails
|
||||
}
|
||||
const previewId = await uploadPreviewImage(filename)
|
||||
const userMetadata = {
|
||||
source: source.type,
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
tags,
|
||||
user_metadata: {
|
||||
source: source.type,
|
||||
if (flags.asyncModelUploadEnabled) {
|
||||
const result = await assetService.uploadAssetAsync({
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
},
|
||||
preview_id: previewId
|
||||
})
|
||||
tags,
|
||||
user_metadata: userMetadata,
|
||||
preview_id: previewId
|
||||
})
|
||||
|
||||
uploadStatus.value = 'success'
|
||||
currentStep.value = 3
|
||||
|
||||
// Refresh model caches for all node types that use this model category
|
||||
if (selectedModelType.value) {
|
||||
const providers = modelToNodeStore.getAllNodeProviders(
|
||||
selectedModelType.value
|
||||
)
|
||||
await Promise.all(
|
||||
providers.map((provider) =>
|
||||
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
|
||||
)
|
||||
)
|
||||
if (result.type === 'async' && result.task.status !== 'completed') {
|
||||
if (selectedModelType.value) {
|
||||
assetDownloadStore.trackDownload(
|
||||
result.task.task_id,
|
||||
selectedModelType.value
|
||||
)
|
||||
}
|
||||
uploadStatus.value = 'processing'
|
||||
} else {
|
||||
uploadStatus.value = 'success'
|
||||
await refreshModelCaches()
|
||||
}
|
||||
currentStep.value = 3
|
||||
} else {
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
tags,
|
||||
user_metadata: userMetadata,
|
||||
preview_id: previewId
|
||||
})
|
||||
uploadStatus.value = 'success'
|
||||
await refreshModelCaches()
|
||||
currentStep.value = 3
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to upload asset:', error)
|
||||
uploadStatus.value = 'error'
|
||||
uploadError.value =
|
||||
error instanceof Error ? error.message : 'Failed to upload model'
|
||||
currentStep.value = 3
|
||||
return false
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
return uploadStatus.value !== 'error'
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
|
||||
@@ -58,6 +58,17 @@ const zAssetMetadata = z.object({
|
||||
validation: zValidationResult.optional()
|
||||
})
|
||||
|
||||
const zAsyncUploadTask = z.object({
|
||||
task_id: z.string(),
|
||||
status: z.enum(['created', 'running', 'completed', 'failed']),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
const zAsyncUploadResponse = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('sync'), asset: zAsset }),
|
||||
z.object({ type: z.literal('async'), task: zAsyncUploadTask })
|
||||
])
|
||||
|
||||
// Filename validation schema
|
||||
export const assetFilenameSchema = z
|
||||
.string()
|
||||
@@ -69,11 +80,13 @@ export const assetFilenameSchema = z
|
||||
// Export schemas following repository patterns
|
||||
export const assetItemSchema = zAsset
|
||||
export const assetResponseSchema = zAssetResponse
|
||||
export const asyncUploadResponseSchema = zAsyncUploadResponse
|
||||
|
||||
// Export types derived from Zod schemas
|
||||
export type AssetItem = z.infer<typeof zAsset>
|
||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||
export type AssetMetadata = z.infer<typeof zAssetMetadata>
|
||||
export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
|
||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import { fromZodError } from 'zod-validation-error'
|
||||
import { st } from '@/i18n'
|
||||
import {
|
||||
assetItemSchema,
|
||||
assetResponseSchema
|
||||
assetResponseSchema,
|
||||
asyncUploadResponseSchema
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetMetadata,
|
||||
AssetResponse,
|
||||
AsyncUploadResponse,
|
||||
ModelFile,
|
||||
ModelFolder
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
@@ -46,6 +48,7 @@ function getLocalizedErrorMessage(errorCode: string): string {
|
||||
}
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 500
|
||||
|
||||
@@ -445,6 +448,72 @@ function createAssetService() {
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an asset asynchronously using the /api/assets/download endpoint
|
||||
* Returns immediately with either the asset (if already exists) or a task to track
|
||||
*
|
||||
* @param params - Upload parameters
|
||||
* @param params.source_url - HTTP/HTTPS URL to download from
|
||||
* @param params.tags - Optional freeform tags
|
||||
* @param params.user_metadata - Optional custom metadata object
|
||||
* @param params.preview_id - Optional UUID for preview asset
|
||||
* @returns Promise<AsyncUploadResponse> - Either sync asset or async task info
|
||||
* @throws Error if upload fails
|
||||
*/
|
||||
async function uploadAssetAsync(params: {
|
||||
source_url: string
|
||||
tags?: string[]
|
||||
user_metadata?: Record<string, unknown>
|
||||
preview_id?: string
|
||||
}): Promise<AsyncUploadResponse> {
|
||||
const res = await api.fetchApi(ASSETS_DOWNLOAD_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
st(
|
||||
'assetBrowser.errorUploadFailed',
|
||||
'Failed to upload asset. Please try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.status === 202) {
|
||||
const result = asyncUploadResponseSchema.safeParse({
|
||||
type: 'async',
|
||||
task: data
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
st(
|
||||
'assetBrowser.errorUploadFailed',
|
||||
'Failed to parse async upload response. Please try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
const result = asyncUploadResponseSchema.safeParse({
|
||||
type: 'sync',
|
||||
asset: data
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
st(
|
||||
'assetBrowser.errorUploadFailed',
|
||||
'Failed to parse sync upload response. Please try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -456,7 +525,8 @@ function createAssetService() {
|
||||
updateAsset,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl,
|
||||
uploadAssetFromBase64
|
||||
uploadAssetFromBase64,
|
||||
uploadAssetAsync
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,53 +127,6 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error - Unused function kept for future use
|
||||
async function postSurveyStatus(): Promise<void> {
|
||||
try {
|
||||
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(
|
||||
`Failed to post survey status: ${response.statusText}`
|
||||
)
|
||||
captureApiError(
|
||||
error,
|
||||
'/settings/{key}',
|
||||
'http_error',
|
||||
response.status,
|
||||
'post_survey_status',
|
||||
{
|
||||
route_template: '/settings/{key}',
|
||||
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
|
||||
}
|
||||
)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
// Only capture network errors (not HTTP errors we already captured)
|
||||
if (!isHttpError(error, 'Failed to post survey status:')) {
|
||||
captureApiError(
|
||||
error as Error,
|
||||
'/settings/{key}',
|
||||
'network_error',
|
||||
undefined,
|
||||
'post_survey_status',
|
||||
{
|
||||
route_template: '/settings/{key}',
|
||||
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
|
||||
}
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitSurvey(
|
||||
survey: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -157,7 +157,9 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-start justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
<span
|
||||
class="text-sm font-normal text-foreground leading-relaxed"
|
||||
>
|
||||
{{ t('subscription.videoEstimateLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
@@ -220,16 +222,19 @@
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 underline"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
>
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -19,9 +19,9 @@ export interface TierPricing {
|
||||
}
|
||||
|
||||
export const TIER_PRICING: Record<Exclude<TierKey, 'founder'>, TierPricing> = {
|
||||
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 164 },
|
||||
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 288 },
|
||||
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 821 }
|
||||
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 120 },
|
||||
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 211 },
|
||||
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 600 }
|
||||
}
|
||||
|
||||
interface TierFeatures {
|
||||
|
||||
@@ -35,8 +35,10 @@ export type RemoteConfig = {
|
||||
firebase_config?: FirebaseRuntimeConfig
|
||||
telemetry_disabled_events?: TelemetryEventName[]
|
||||
model_upload_button_enabled?: boolean
|
||||
asset_update_options_enabled?: boolean
|
||||
asset_deletion_enabled?: boolean
|
||||
asset_rename_enabled?: boolean
|
||||
private_models_enabled?: boolean
|
||||
onboarding_survey_enabled?: boolean
|
||||
huggingface_model_import_enabled?: boolean
|
||||
async_model_upload_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -1095,7 +1095,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
id: 'Comfy.Templates.SortBy',
|
||||
name: 'Template library - Sort preference',
|
||||
type: 'hidden',
|
||||
defaultValue: 'newest'
|
||||
defaultValue: 'default'
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1139,5 +1139,13 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'hidden',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.34.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Queue.QPOV2',
|
||||
name: 'Queue Panel V2',
|
||||
type: 'hidden',
|
||||
tooltip: 'Enable the new Assets Panel design with list/grid view toggle',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -197,6 +197,8 @@ export interface TemplateFilterMetadata {
|
||||
selected_runs_on: string[]
|
||||
sort_by:
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
|
||||
@@ -6,7 +6,7 @@ import { i18n, st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { getCategoryIcon } from '@/utils/categoryIcons'
|
||||
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import type {
|
||||
@@ -276,9 +276,18 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
return enhancedTemplates.value
|
||||
}
|
||||
|
||||
if (categoryId === 'basics') {
|
||||
if (categoryId.startsWith('basics-')) {
|
||||
// Filter for templates from categories marked as essential
|
||||
return enhancedTemplates.value.filter((t) => t.isEssential)
|
||||
return enhancedTemplates.value.filter(
|
||||
(t) =>
|
||||
t.isEssential &&
|
||||
t.category?.toLowerCase().replace(/\s+/g, '-') ===
|
||||
categoryId.replace('basics-', '')
|
||||
)
|
||||
}
|
||||
|
||||
if (categoryId === 'popular') {
|
||||
return enhancedTemplates.value
|
||||
}
|
||||
|
||||
if (categoryId === 'partner-nodes') {
|
||||
@@ -333,20 +342,34 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
icon: getCategoryIcon('all')
|
||||
})
|
||||
|
||||
// 2. Basics (isEssential categories) - always second if it exists
|
||||
const essentialCat = coreTemplates.value.find(
|
||||
// 1.5. Popular categories
|
||||
|
||||
items.push({
|
||||
id: 'popular',
|
||||
label: st('templateWorkflows.category.Popular', 'Popular'),
|
||||
icon: 'icon-[lucide--flame]'
|
||||
})
|
||||
|
||||
// 2. Basics (isEssential categories) - always beneath All Templates if they exist
|
||||
const essentialCats = coreTemplates.value.filter(
|
||||
(cat) => cat.isEssential && cat.templates.length > 0
|
||||
)
|
||||
|
||||
if (essentialCat) {
|
||||
const categoryTitle = essentialCat.title ?? 'Getting Started'
|
||||
items.push({
|
||||
id: 'basics',
|
||||
label: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(categoryTitle)}`,
|
||||
categoryTitle
|
||||
),
|
||||
icon: 'icon-[lucide--graduation-cap]'
|
||||
if (essentialCats.length > 0) {
|
||||
essentialCats.forEach((essentialCat) => {
|
||||
const categoryIcon = essentialCat.icon
|
||||
const categoryTitle = essentialCat.title ?? 'Getting Started'
|
||||
const categoryId = generateCategoryId('basics', essentialCat.title)
|
||||
items.push({
|
||||
id: categoryId,
|
||||
label: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(categoryTitle)}`,
|
||||
categoryTitle
|
||||
),
|
||||
icon:
|
||||
categoryIcon ||
|
||||
getCategoryIcon(essentialCat.type || 'getting-started')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -375,7 +398,7 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
const group = categoryGroups.get(categoryGroup)!
|
||||
|
||||
// Generate unique ID for this category
|
||||
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
|
||||
const categoryId = generateCategoryId(categoryGroup, category.title)
|
||||
|
||||
// Store the filter mapping
|
||||
categoryFilters.value.set(categoryId, {
|
||||
|
||||
@@ -32,6 +32,29 @@ export interface TemplateInfo {
|
||||
* Templates with this field will be hidden on local installations temporarily.
|
||||
*/
|
||||
requiresCustomNodes?: string[]
|
||||
/**
|
||||
* Manual ranking boost/demotion for "Recommended" sort. Scale 1-10, default 5.
|
||||
* Higher values promote the template, lower values demote it.
|
||||
*/
|
||||
searchRank?: number
|
||||
/**
|
||||
* Usage score based on real world usage statistics.
|
||||
* Used for popular templates sort and for "Recommended" sort boost.
|
||||
*/
|
||||
usage?: number
|
||||
/**
|
||||
* Manage template's visibility across different distributions by specifying which distributions it should be included on.
|
||||
* If not specified, the template will be included on all distributions.
|
||||
*/
|
||||
includeOnDistributions?: TemplateIncludeOnDistributionEnum[]
|
||||
}
|
||||
|
||||
export enum TemplateIncludeOnDistributionEnum {
|
||||
Cloud = 'cloud',
|
||||
Local = 'local',
|
||||
Desktop = 'desktop',
|
||||
Mac = 'mac',
|
||||
Windows = 'windows'
|
||||
}
|
||||
|
||||
export interface WorkflowTemplates {
|
||||
|
||||
@@ -368,7 +368,8 @@ export const useImagePreviewWidget = () => {
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(node, inputSpec.name, {
|
||||
serialize: false
|
||||
serialize: false,
|
||||
canvasOnly: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,17 @@ const zLogRawResponse = z.object({
|
||||
|
||||
const zFeatureFlagsWsMessage = z.record(z.string(), z.any())
|
||||
|
||||
const zAssetDownloadWsMessage = z.object({
|
||||
task_id: z.string(),
|
||||
asset_id: z.string(),
|
||||
asset_name: z.string(),
|
||||
bytes_total: z.number(),
|
||||
bytes_downloaded: z.number(),
|
||||
progress: z.number(),
|
||||
status: z.enum(['created', 'running', 'completed', 'failed']),
|
||||
error: z.string().optional()
|
||||
})
|
||||
|
||||
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
|
||||
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
|
||||
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
|
||||
@@ -154,6 +165,7 @@ export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
|
||||
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
|
||||
export type AssetDownloadWsMessage = z.infer<typeof zAssetDownloadWsMessage>
|
||||
// End of ws messages
|
||||
|
||||
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>
|
||||
@@ -491,6 +503,7 @@ const zSettings = z.object({
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy.Queue.QPOV2': z.boolean(),
|
||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||
'Comfy-Desktop.SendStatistics': z.boolean(),
|
||||
'Comfy-Desktop.WindowStyle': z.string(),
|
||||
@@ -526,6 +539,8 @@ const zSettings = z.object({
|
||||
'Comfy.Templates.SelectedRunsOn': z.array(z.string()),
|
||||
'Comfy.Templates.SortBy': z.enum([
|
||||
'default',
|
||||
'recommended',
|
||||
'popular',
|
||||
'alphabetical',
|
||||
'newest',
|
||||
'vram-low-to-high',
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
AssetDownloadWsMessage,
|
||||
EmbeddingsResponse,
|
||||
ExecutedWsMessage,
|
||||
ExecutingWsMessage,
|
||||
@@ -153,6 +154,7 @@ interface BackendApiCalls {
|
||||
progress_text: ProgressTextWsMessage
|
||||
progress_state: ProgressStateWsMessage
|
||||
feature_flags: FeatureFlagsWsMessage
|
||||
asset_download: AssetDownloadWsMessage
|
||||
}
|
||||
|
||||
/** Dictionary of all api calls */
|
||||
@@ -664,6 +666,7 @@ export class ComfyApi extends EventTarget {
|
||||
case 'logs':
|
||||
case 'b_preview':
|
||||
case 'notification':
|
||||
case 'asset_download':
|
||||
this.dispatchCustomEvent(msg.type, msg.data)
|
||||
break
|
||||
case 'feature_flags':
|
||||
|
||||
171
src/stores/assetDownloadStore.ts
Normal file
171
src/stores/assetDownloadStore.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { AssetDownloadWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
interface AssetDownload {
|
||||
taskId: string
|
||||
assetId: string
|
||||
assetName: string
|
||||
bytesTotal: number
|
||||
bytesDownloaded: number
|
||||
progress: number
|
||||
status: 'created' | 'running' | 'completed' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface CompletedDownload {
|
||||
taskId: string
|
||||
modelType: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const PROGRESS_TOAST_INTERVAL_MS = 5000
|
||||
const PROCESSED_TASK_CLEANUP_MS = 60000
|
||||
const MAX_COMPLETED_DOWNLOADS = 10
|
||||
|
||||
export const useAssetDownloadStore = defineStore('assetDownload', () => {
|
||||
const toastStore = useToastStore()
|
||||
|
||||
/** Map of task IDs to their download progress data */
|
||||
const activeDownloads = ref<Map<string, AssetDownload>>(new Map())
|
||||
|
||||
/** Map of task IDs to model types, used to track which model type to refresh after download completes */
|
||||
const pendingModelTypes = new Map<string, string>()
|
||||
|
||||
/** Map of task IDs to timestamps, used to throttle progress toast notifications */
|
||||
const lastToastTime = new Map<string, number>()
|
||||
|
||||
/** Set of task IDs that have reached a terminal state (completed/failed), prevents duplicate processing */
|
||||
const processedTaskIds = new Set<string>()
|
||||
|
||||
/** Reactive signal for completed downloads */
|
||||
const completedDownloads = ref<CompletedDownload[]>([])
|
||||
|
||||
const hasActiveDownloads = computed(() => activeDownloads.value.size > 0)
|
||||
const downloadList = computed(() =>
|
||||
Array.from(activeDownloads.value.values())
|
||||
)
|
||||
|
||||
/**
|
||||
* Associates a download task with its model type for later use when the download completes.
|
||||
* Intended for external callers (e.g., useUploadModelWizard) to register async downloads.
|
||||
*/
|
||||
function trackDownload(taskId: string, modelType: string) {
|
||||
pendingModelTypes.set(taskId, modelType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles asset download WebSocket events. Updates download progress, manages toast notifications,
|
||||
* and tracks completed downloads. Prevents duplicate processing of terminal states (completed/failed).
|
||||
*/
|
||||
function handleAssetDownload(e: CustomEvent<AssetDownloadWsMessage>) {
|
||||
const data = e.detail
|
||||
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
if (processedTaskIds.has(data.task_id)) return
|
||||
processedTaskIds.add(data.task_id)
|
||||
}
|
||||
|
||||
const download: AssetDownload = {
|
||||
taskId: data.task_id,
|
||||
assetId: data.asset_id,
|
||||
assetName: data.asset_name,
|
||||
bytesTotal: data.bytes_total,
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
progress: data.progress,
|
||||
status: data.status,
|
||||
error: data.error
|
||||
}
|
||||
|
||||
if (data.status === 'completed') {
|
||||
activeDownloads.value.delete(data.task_id)
|
||||
lastToastTime.delete(data.task_id)
|
||||
const modelType = pendingModelTypes.get(data.task_id)
|
||||
if (modelType) {
|
||||
// Emit completed download signal for other stores to react to
|
||||
const newDownload: CompletedDownload = {
|
||||
taskId: data.task_id,
|
||||
modelType,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// Keep only the last MAX_COMPLETED_DOWNLOADS items (FIFO)
|
||||
const updated = [...completedDownloads.value, newDownload]
|
||||
if (updated.length > MAX_COMPLETED_DOWNLOADS) {
|
||||
updated.shift()
|
||||
}
|
||||
completedDownloads.value = updated
|
||||
|
||||
pendingModelTypes.delete(data.task_id)
|
||||
}
|
||||
setTimeout(
|
||||
() => processedTaskIds.delete(data.task_id),
|
||||
PROCESSED_TASK_CLEANUP_MS
|
||||
)
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: st('assetBrowser.download.complete', 'Download complete'),
|
||||
detail: data.asset_name,
|
||||
life: 5000
|
||||
})
|
||||
} else if (data.status === 'failed') {
|
||||
activeDownloads.value.delete(data.task_id)
|
||||
lastToastTime.delete(data.task_id)
|
||||
pendingModelTypes.delete(data.task_id)
|
||||
setTimeout(
|
||||
() => processedTaskIds.delete(data.task_id),
|
||||
PROCESSED_TASK_CLEANUP_MS
|
||||
)
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: st('assetBrowser.download.failed', 'Download failed'),
|
||||
detail: data.error || data.asset_name,
|
||||
life: 8000
|
||||
})
|
||||
} else {
|
||||
activeDownloads.value.set(data.task_id, download)
|
||||
|
||||
const now = Date.now()
|
||||
const lastTime = lastToastTime.get(data.task_id) ?? 0
|
||||
const shouldShowToast = now - lastTime >= PROGRESS_TOAST_INTERVAL_MS
|
||||
|
||||
if (shouldShowToast) {
|
||||
lastToastTime.set(data.task_id, now)
|
||||
const progressPercent = Math.round(data.progress * 100)
|
||||
toastStore.add({
|
||||
severity: 'info',
|
||||
summary: st('assetBrowser.download.inProgress', 'Downloading...'),
|
||||
detail: `${data.asset_name} (${progressPercent}%)`,
|
||||
life: PROGRESS_TOAST_INTERVAL_MS,
|
||||
closable: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stopListener: (() => void) | undefined
|
||||
|
||||
function setup() {
|
||||
stopListener = useEventListener(api, 'asset_download', handleAssetDownload)
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
stopListener?.()
|
||||
stopListener = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
activeDownloads,
|
||||
hasActiveDownloads,
|
||||
downloadList,
|
||||
completedDownloads,
|
||||
trackDownload,
|
||||
setup,
|
||||
teardown
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,9 @@ vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getHistory: vi.fn(),
|
||||
internalURL: vi.fn((path) => `http://localhost:3000${path}`),
|
||||
apiURL: vi.fn((path) => `http://localhost:3000/api${path}`),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
user: 'test-user'
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive, ref } from 'vue'
|
||||
import { computed, shallowReactive, ref, watch } from 'vue'
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
mapTaskOutputToAssetItem
|
||||
@@ -12,6 +12,8 @@ import type { TaskItem } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { TaskItemImpl } from './queueStore'
|
||||
import { useAssetDownloadStore } from './assetDownloadStore'
|
||||
import { useModelToNodeStore } from './modelToNodeStore'
|
||||
|
||||
const INPUT_LIMIT = 100
|
||||
|
||||
@@ -93,6 +95,9 @@ const BATCH_SIZE = 200
|
||||
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
|
||||
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
// Pagination state
|
||||
const historyOffset = ref(0)
|
||||
const hasMoreHistory = ref(true)
|
||||
@@ -345,6 +350,35 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
updateModelsForNodeType
|
||||
} = getModelState()
|
||||
|
||||
// Watch for completed downloads and refresh model caches
|
||||
watch(
|
||||
() => assetDownloadStore.completedDownloads.at(-1),
|
||||
async (latestDownload) => {
|
||||
if (!latestDownload) return
|
||||
|
||||
const { modelType } = latestDownload
|
||||
|
||||
const providers = modelToNodeStore
|
||||
.getAllNodeProviders(modelType)
|
||||
.filter((provider) => provider.nodeDef?.name)
|
||||
const results = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
updateModelsForNodeType(provider.nodeDef.name).then(
|
||||
() => provider.nodeDef.name
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`Failed to refresh model cache for provider: ${result.reason}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
// States
|
||||
inputAssets,
|
||||
|
||||
66
src/stores/templateRankingStore.ts
Normal file
66
src/stores/templateRankingStore.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Store for template ranking scores.
|
||||
* Loads pre-computed usage scores from static JSON.
|
||||
* Internal ranks come from template.searchRank in index.json.
|
||||
* See docs/TEMPLATE_RANKING.md for details.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useTemplateRankingStore = defineStore('templateRanking', () => {
|
||||
const largestUsageScore = ref<number>()
|
||||
|
||||
const normalizeUsageScore = (usage: number): number => {
|
||||
return usage / (largestUsageScore.value ?? usage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute freshness score based on template date.
|
||||
* Returns 1.0 for brand new, decays to 0.1 over ~6 months.
|
||||
*/
|
||||
const computeFreshness = (dateStr: string | undefined): number => {
|
||||
if (!dateStr) return 0.5 // Default for templates without dates
|
||||
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return 0.5
|
||||
|
||||
const daysSinceAdded = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)
|
||||
return Math.max(0.1, 1.0 / (1 + daysSinceAdded / 90))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute composite score for "default" sort.
|
||||
* Formula: usage × 0.5 + internal × 0.3 + freshness × 0.2
|
||||
*/
|
||||
const computeDefaultScore = (
|
||||
dateStr: string | undefined,
|
||||
searchRank: number | undefined,
|
||||
usage: number = 0
|
||||
): number => {
|
||||
const internal = (searchRank ?? 5) / 10 // Normalize 1-10 to 0-1
|
||||
const freshness = computeFreshness(dateStr)
|
||||
|
||||
return normalizeUsageScore(usage) * 0.5 + internal * 0.3 + freshness * 0.2
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute composite score for "popular" sort.
|
||||
* Formula: usage × 0.9 + freshness × 0.1
|
||||
*/
|
||||
const computePopularScore = (
|
||||
dateStr: string | undefined,
|
||||
usage: number = 0
|
||||
): number => {
|
||||
const freshness = computeFreshness(dateStr)
|
||||
|
||||
return normalizeUsageScore(usage) * 0.9 + freshness * 0.1
|
||||
}
|
||||
|
||||
return {
|
||||
largestUsageScore,
|
||||
computeFreshness,
|
||||
computeDefaultScore,
|
||||
computePopularScore
|
||||
}
|
||||
})
|
||||
@@ -50,3 +50,13 @@ export const getCategoryIcon = (categoryId: string): string => {
|
||||
// Return mapped icon or fallback to folder
|
||||
return iconMap[categoryId.toLowerCase()] || 'icon-[lucide--folder]'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique category ID from a category group and title
|
||||
*/
|
||||
export function generateCategoryId(
|
||||
categoryGroup: string,
|
||||
categoryTitle: string
|
||||
) {
|
||||
return `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${categoryTitle.toLowerCase().replace(/\s+/g, '-')}`
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -87,6 +88,7 @@ const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const queueStore = useQueueStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
@@ -254,6 +256,7 @@ onMounted(() => {
|
||||
api.addEventListener('reconnecting', onReconnecting)
|
||||
api.addEventListener('reconnected', onReconnected)
|
||||
executionStore.bindExecutionEvents()
|
||||
assetDownloadStore.setup()
|
||||
|
||||
try {
|
||||
init()
|
||||
@@ -270,6 +273,7 @@ onBeforeUnmount(() => {
|
||||
api.removeEventListener('reconnecting', onReconnecting)
|
||||
api.removeEventListener('reconnected', onReconnected)
|
||||
executionStore.unbindExecutionEvents()
|
||||
assetDownloadStore.teardown()
|
||||
|
||||
// Clean up page visibility listener
|
||||
if (visibilityListener) {
|
||||
|
||||
135
tests-ui/stores/templateRankingStore.test.ts
Normal file
135
tests-ui/stores/templateRankingStore.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('templateRankingStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('computeFreshness', () => {
|
||||
it('returns 1.0 for brand new template (today)', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const freshness = store.computeFreshness(today)
|
||||
expect(freshness).toBeCloseTo(1.0, 1)
|
||||
})
|
||||
|
||||
it('returns ~0.5 for 90-day old template', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
const freshness = store.computeFreshness(ninetyDaysAgo)
|
||||
expect(freshness).toBeCloseTo(0.5, 1)
|
||||
})
|
||||
|
||||
it('returns 0.1 minimum for very old template', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
const freshness = store.computeFreshness('2020-01-01')
|
||||
expect(freshness).toBe(0.1)
|
||||
})
|
||||
|
||||
it('returns 0.5 for undefined date', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
expect(store.computeFreshness(undefined)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('returns 0.5 for invalid date', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
expect(store.computeFreshness('not-a-date')).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeDefaultScore', () => {
|
||||
it('uses default searchRank of 5 when not provided', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
// Set largestUsageScore to avoid NaN when usage is 0
|
||||
store.largestUsageScore = 100
|
||||
const score = store.computeDefaultScore('2024-01-01', undefined, 0)
|
||||
// With no usage score loaded, usage = 0
|
||||
// internal = 5/10 = 0.5, freshness ~0.1 (old date)
|
||||
// score = 0 * 0.5 + 0.5 * 0.3 + 0.1 * 0.2 = 0.15 + 0.02 = 0.17
|
||||
expect(score).toBeCloseTo(0.17, 1)
|
||||
})
|
||||
|
||||
it('high searchRank (10) boosts score', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
const lowRank = store.computeDefaultScore('2024-01-01', 1, 0)
|
||||
const highRank = store.computeDefaultScore('2024-01-01', 10, 0)
|
||||
expect(highRank).toBeGreaterThan(lowRank)
|
||||
})
|
||||
|
||||
it('low searchRank (1) demotes score', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
const neutral = store.computeDefaultScore('2024-01-01', 5, 0)
|
||||
const demoted = store.computeDefaultScore('2024-01-01', 1, 0)
|
||||
expect(demoted).toBeLessThan(neutral)
|
||||
})
|
||||
|
||||
it('searchRank difference is significant', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
const rank1 = store.computeDefaultScore('2024-01-01', 1, 0)
|
||||
const rank10 = store.computeDefaultScore('2024-01-01', 10, 0)
|
||||
// Difference should be 0.9 * 0.3 = 0.27 (30% weight, 0.9 range)
|
||||
expect(rank10 - rank1).toBeCloseTo(0.27, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computePopularScore', () => {
|
||||
it('does not use searchRank', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
// Popular score ignores searchRank - just usage + freshness
|
||||
const score1 = store.computePopularScore('2024-01-01', 0)
|
||||
const score2 = store.computePopularScore('2024-01-01', 0)
|
||||
expect(score1).toBe(score2)
|
||||
})
|
||||
|
||||
it('newer templates score higher', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const oldScore = store.computePopularScore('2020-01-01', 0)
|
||||
const newScore = store.computePopularScore(today, 0)
|
||||
expect(newScore).toBeGreaterThan(oldScore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchRank edge cases', () => {
|
||||
it('handles searchRank of 0 (should still work, treated as very low)', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
const score = store.computeDefaultScore('2024-01-01', 0, 0)
|
||||
expect(score).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('handles searchRank above 10 (clamping not enforced, but works)', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
const rank10 = store.computeDefaultScore('2024-01-01', 10, 0)
|
||||
const rank15 = store.computeDefaultScore('2024-01-01', 15, 0)
|
||||
expect(rank15).toBeGreaterThan(rank10)
|
||||
})
|
||||
|
||||
it('handles negative searchRank', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
const score = store.computeDefaultScore('2024-01-01', -5, 0)
|
||||
// Should still compute, just negative contribution from searchRank
|
||||
expect(typeof score).toBe('number')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user