Merge branch 'main' into pysssss/fix-panel-node-title-reactivity

This commit is contained in:
pythongosssss
2026-01-07 10:15:56 +00:00
committed by GitHub
84 changed files with 8842 additions and 7637 deletions

View File

@@ -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
View 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.
---

View File

@@ -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",

View File

@@ -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);

View File

@@ -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>

View File

@@ -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
})

View File

@@ -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="'&rarr;'"></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 })
}
]

View File

@@ -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,

View File

@@ -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')

View File

@@ -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)

View File

@@ -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' &&

View File

@@ -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',

View File

@@ -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
)
)
}
})

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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()
})

View File

@@ -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) => {

View File

@@ -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]
}

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -156,8 +156,9 @@ class Load3DConfiguration {
return {
upDirection: 'original',
materialMode: 'original'
} as ModelConfig
materialMode: 'original',
showSkeleton: false
}
}
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View File

@@ -34,6 +34,7 @@ export interface SceneConfig {
export interface ModelConfig {
upDirection: UpDirection
materialMode: MaterialMode
showSkeleton: boolean
}
export interface CameraConfig {

View File

@@ -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
})

View File

@@ -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)

View File

@@ -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(

View File

@@ -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, {

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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: []
}

View File

@@ -338,6 +338,7 @@ export interface INodeFlags {
*/
export interface IWidgetLocator {
name: string
type?: string
}
export interface INodeInputSlot extends INodeSlot {

View File

@@ -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'
}
}

View File

@@ -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])
})

View File

@@ -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')
}
)

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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
}
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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()
}
})

View File

@@ -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]
>[]
}
}

View File

@@ -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>
}
/**

View File

@@ -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 })

View File

@@ -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)"
},

View File

@@ -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."
}

View File

@@ -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.",

View File

@@ -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

View File

@@ -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,

View File

@@ -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')
)

View File

@@ -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 = () => {

View File

@@ -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 ?? '')

View 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>

View File

@@ -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"

View File

@@ -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<{

View File

@@ -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

View File

@@ -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

View File

@@ -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
}>()

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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> {

View File

@@ -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="'&rarr;'"></span>
</a>
</div>
</Popover>

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}
]

View File

@@ -197,6 +197,8 @@ export interface TemplateFilterMetadata {
selected_runs_on: string[]
sort_by:
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'

View File

@@ -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, {

View File

@@ -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 {

View File

@@ -368,7 +368,8 @@ export const useImagePreviewWidget = () => {
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
serialize: false,
canvasOnly: true
})
)
}

View File

@@ -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',

View File

@@ -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':

View 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
}
})

View File

@@ -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'
}
}))

View File

@@ -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,

View 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
}
})

View File

@@ -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, '-')}`
}

View File

@@ -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) {

View 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')
})
})
})