Merge remote-tracking branch 'origin/main' into bl-teenage-planarian

This commit is contained in:
Benjamin Lu
2025-12-29 12:19:29 -08:00
71 changed files with 3026 additions and 999 deletions

View File

@@ -10,7 +10,7 @@ module.exports = defineConfig({
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,86 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe('Properties panel position', () => {
test.beforeEach(async ({ comfyPage }) => {
// Open a sidebar tab to ensure sidebar is visible
await comfyPage.menu.nodeLibraryTab.open()
await comfyPage.actionbar.propertiesButton.click()
})
test('positions on the right when sidebar is on the left', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.nextFrame()
const propertiesPanel = comfyPage.page.getByTestId('properties-panel')
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
await expect(propertiesPanel).toBeVisible()
await expect(sidebar).toBeVisible()
const propsBoundingBox = await propertiesPanel.boundingBox()
const sidebarBoundingBox = await sidebar.boundingBox()
expect(propsBoundingBox).not.toBeNull()
expect(sidebarBoundingBox).not.toBeNull()
// Properties panel should be to the right of the sidebar
expect(propsBoundingBox!.x).toBeGreaterThan(
sidebarBoundingBox!.x + sidebarBoundingBox!.width
)
})
test('positions on the left when sidebar is on the right', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
const propertiesPanel = comfyPage.page.getByTestId('properties-panel')
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
await expect(propertiesPanel).toBeVisible()
await expect(sidebar).toBeVisible()
const propsBoundingBox = await propertiesPanel.boundingBox()
const sidebarBoundingBox = await sidebar.boundingBox()
expect(propsBoundingBox).not.toBeNull()
expect(sidebarBoundingBox).not.toBeNull()
// Properties panel should be to the left of the sidebar
expect(propsBoundingBox!.x + propsBoundingBox!.width).toBeLessThan(
sidebarBoundingBox!.x
)
})
test('close button icon updates based on sidebar location', async ({
comfyPage
}) => {
const propertiesPanel = comfyPage.page.getByTestId('properties-panel')
// When sidebar is on the left, panel is on the right
await comfyPage.setSetting('Comfy.Sidebar.Location', 'left')
await comfyPage.nextFrame()
await expect(propertiesPanel).toBeVisible()
const closeButtonLeft = propertiesPanel
.locator('button[aria-pressed]')
.locator('i')
await expect(closeButtonLeft).toBeVisible()
await expect(closeButtonLeft).toHaveClass(/lucide--panel-right/)
// When sidebar is on the right, panel is on the left
await comfyPage.setSetting('Comfy.Sidebar.Location', 'right')
await comfyPage.nextFrame()
const closeButtonRight = propertiesPanel
.locator('button[aria-pressed]')
.locator('i')
await expect(closeButtonRight).toBeVisible()
await expect(closeButtonRight).toHaveClass(/lucide--panel-left/)
})
})

View File

@@ -109,22 +109,27 @@ test.describe('Templates', () => {
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
// Load the templates dialog and wait for the French index file request
const requestPromise = comfyPage.page.waitForRequest(
'**/templates/index.fr.json'
)
// Set locale to French before opening templates
await comfyPage.setSetting('Comfy.Locale', 'fr')
await comfyPage.executeCommand('Comfy.BrowseTemplates')
const request = await requestPromise
const dialog = comfyPage.page.getByRole('dialog').filter({
has: comfyPage.page.getByRole('heading', { name: 'Modèles', exact: true })
})
await expect(dialog).toBeVisible()
// Verify French index was requested
expect(request.url()).toContain('templates/index.fr.json')
// Validate that French-localized strings from the templates index are rendered
await expect(
dialog.getByRole('heading', { name: 'Modèles', exact: true })
).toBeVisible()
await expect(
dialog.getByRole('button', { name: 'Tous les modèles', exact: true })
).toBeVisible()
await expect(comfyPage.templates.content).toBeVisible()
// Ensure the English fallback copy is not shown anywhere
await expect(
comfyPage.page.getByText('All Templates', { exact: true })
).toHaveCount(0)
})
test('Falls back to English templates when locale file not found', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.36.8",
"version": "1.36.12",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -11910,6 +11910,8 @@ export interface operations {
"application/json": {
/** @description Optional URL to redirect the customer after they're done with the billing portal */
return_url?: string;
/** @description Optional target subscription tier. When provided, creates a deep link directly to the subscription update confirmation screen with this tier pre-selected. */
target_tier?: "standard" | "creator" | "pro" | "standard-yearly" | "creator-yearly" | "pro-yearly";
};
};
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -22,29 +22,38 @@
state-storage="local"
@resizestart="onResizestart"
>
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="sidebarLocation === 'left' && !focusMode"
:class="
cn(
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
v-if="
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
"
:min-size="10"
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'left' ? 10 : 15"
:size="20"
:style="{
display:
sidebarPanelVisible && sidebarLocation === 'left'
? 'flex'
: 'none'
}"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
"
>
<slot
v-if="sidebarPanelVisible && sidebarLocation === 'left'"
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
name="side-bar-panel"
/>
<slot
v-else-if="sidebarLocation === 'right'"
name="right-side-panel"
/>
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="80" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
@@ -73,38 +82,33 @@
</Splitter>
</SplitterPanel>
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="sidebarLocation === 'right' && !focusMode"
:class="
cn(
'side-bar-panel pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
v-if="
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
"
:min-size="10"
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
sidebarPanelVisible && 'min-w-78'
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'right' ? 10 : 15"
:size="20"
:style="{
display:
sidebarPanelVisible && sidebarLocation === 'right'
? 'flex'
: 'none'
}"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
"
>
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
<slot
v-if="sidebarPanelVisible && sidebarLocation === 'right'"
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
name="side-bar-panel"
/>
</SplitterPanel>
<!-- Right Side Panel - independent of sidebar -->
<SplitterPanel
v-if="rightSidePanelVisible && !focusMode"
class="bg-comfy-menu-bg pointer-events-auto"
:min-size="15"
:size="20"
>
<slot name="right-side-panel" />
</SplitterPanel>
</Splitter>
</div>
</div>
@@ -117,6 +121,7 @@ import Splitter from 'primevue/splitter'
import type { SplitterResizeStartEvent } from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -128,6 +133,7 @@ const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const sidebarTabStore = useSidebarTabStore()
const { t } = useI18n()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
@@ -159,12 +165,25 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
}
/*
* Force refresh the splitter when right panel visibility changes to recalculate the width
* Force refresh the splitter when right panel visibility or sidebar location changes
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return rightSidePanelVisible.value
? 'main-splitter-with-right-panel'
: 'main-splitter'
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
const lastPanelStyle = computed(() => {
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
</script>

View File

@@ -197,7 +197,16 @@ const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const promptId = item.taskRef?.promptId
if (!promptId) return
await api.interrupt(promptId)
if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution
await api.interrupt(promptId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
await api.deleteItem('queue', promptId)
await queueStore.update()
}
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {

View File

@@ -9,6 +9,7 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
@@ -22,11 +23,23 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
const canvasStore = useCanvasStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { selectedItems } = storeToRefs(canvasStore)
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
// Panel is on the left when sidebar is on the right, and vice versa
const panelIcon = computed(() =>
sidebarLocation.value === 'right'
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-right]'
)
const hasSelection = computed(() => selectedItems.value.length > 0)
const selectedNodes = computed((): LGraphNode[] => {
@@ -160,7 +173,7 @@ function handleTitleCancel() {
:aria-label="t('rightSidePanel.togglePanel')"
@click="closePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
<i :class="cn(panelIcon, 'size-4')" />
</Button>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { computed, provide } from 'vue'
import { useI18n } from 'vue-i18n'
import { useReactiveWidgetValue } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -71,7 +72,7 @@ const displayLabel = computed(
<component
:is="getWidgetComponent(widget)"
:widget="widget"
:model-value="widget.value"
:model-value="useReactiveWidgetValue(widget)"
:node-id="String(node.id)"
:node-type="node.type"
:class="cn('col-span-1', shouldExpand(widget.type) && 'min-h-36')"

View File

@@ -11,6 +11,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@/utils/formatUtil'
/**
@@ -102,8 +103,11 @@ export const useFirebaseAuthActions = () => {
window.open(response.checkout_url, '_blank')
}, reportError)
const accessBillingPortal = wrapWithErrorHandlingAsync(async () => {
const response = await authStore.accessBillingPortal()
const accessBillingPortal = wrapWithErrorHandlingAsync<
[targetTier?: BillingPortalTargetTier],
void
>(async (targetTier) => {
const response = await authStore.accessBillingPortal(targetTier)
if (!response.billing_portal_url) {
throw new Error(
t('toastMessages.failedToAccessBillingPortal', {

View File

@@ -3,7 +3,7 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { customRef, reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
@@ -90,6 +90,23 @@ export interface GraphNodeManager {
cleanup(): void
}
function widgetWithVueTrack(
widget: IBaseWidget
): asserts widget is IBaseWidget & { vueTrack: () => void } {
if (widget.vueTrack) return
customRef((track, trigger) => {
widget.callback = useChainCallback(widget.callback, trigger)
widget.vueTrack = track
return { get() {}, set() {} }
})
}
export function useReactiveWidgetValue(widget: IBaseWidget) {
widgetWithVueTrack(widget)
widget.vueTrack()
return widget.value
}
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
@@ -106,6 +123,37 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
return subNode?.type
}
/**
* Validates that a value is a valid WidgetValue type
*/
const normalizeWidgetValue = (value: unknown): WidgetValue => {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
@@ -113,19 +161,6 @@ export function safeWidgetMapper(
const nodeDefStore = useNodeDefStore()
return function (widget) {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
const borderStyle = widget.promoted
@@ -133,13 +168,18 @@ export function safeWidgetMapper(
: widget.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
widget.callback?.(value)
}
return {
name: widget.name,
type: widget.type,
value: value,
value: useReactiveWidgetValue(widget),
borderStyle,
callback: widget.callback,
callback,
controlWidget: getControlWidget(widget),
isDOMWidget: isDOMWidget(widget),
label: widget.label,
@@ -286,128 +326,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
return nodeRefs.get(id)
}
/**
* Validates that a value is a valid WidgetValue type
*/
const validateWidgetValue = (value: unknown): WidgetValue => {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Updates Vue state when widget values change
*/
const updateVueWidgetState = (
nodeId: string,
widgetName: string,
value: unknown
): void => {
try {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
)
// Create a completely new object to ensure Vue reactivity triggers
const updatedData = {
...currentData,
widgets: updatedWidgets
}
vueNodeData.set(nodeId, updatedData)
} catch (error) {
// Ignore widget update errors to prevent cascade failures
}
}
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedWidgetCallback = (
widget: IBaseWidget, // LiteGraph widget with minimal typing
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
// Validate that the value is of an acceptable type
if (
value !== null &&
value !== undefined &&
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
typeof value !== 'object'
) {
console.warn(`Invalid widget value type: ${typeof value}`)
updateInProgress = false
return
}
// Always update widget.value to ensure sync
widget.value = value ?? undefined
// 2. Call the original callback if it exists
if (originalCallback && widget.type !== 'asset') {
originalCallback.call(widget, value)
}
// 3. Update Vue state to maintain synchronization
updateVueWidgetState(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedWidgetCallback(
widget,
originalCallback,
nodeId
)
})
}
const syncWithGraph = () => {
if (!graph?._nodes) return
@@ -428,9 +346,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Store non-reactive reference
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
@@ -449,9 +364,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))

View File

@@ -116,6 +116,31 @@ const makeOmniProDurationCalculator =
return formatCreditsLabel(cost)
}
const klingMotionControlPricingCalculator: PricingFunction = (
node: LGraphNode
): string => {
const modeWidget = node.widgets?.find(
(w) => w.name === 'mode'
) as IComboWidget
if (!modeWidget) {
return formatCreditsListLabel([0.07, 0.112], {
suffix: '/second',
note: '(std/pro)'
})
}
const mode = String(modeWidget.value).toLowerCase()
if (mode === 'pro') return formatCreditsLabel(0.112, { suffix: '/second' })
if (mode === 'std') return formatCreditsLabel(0.07, { suffix: '/second' })
return formatCreditsListLabel([0.07, 0.112], {
suffix: '/second',
note: '(std/pro)'
})
}
const pixversePricingCalculator = (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration_seconds'
@@ -1034,6 +1059,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
KlingOmniProVideoToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.168)
},
KlingMotionControl: {
displayPrice: klingMotionControlPricingCalculator
},
KlingOmniProEditVideoNode: {
displayPrice: formatCreditsLabel(0.168, { suffix: '/second' })
},
@@ -2117,6 +2145,7 @@ export const useNodePricing = () => {
KlingOmniProFirstLastFrameNode: ['duration'],
KlingOmniProImageToVideoNode: ['duration'],
KlingOmniProVideoToVideoNode: ['duration'],
KlingMotionControl: ['mode'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],

View File

@@ -13,7 +13,8 @@ export enum ServerFeatureFlag {
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled'
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled'
}
/**
@@ -62,6 +63,16 @@ export function useFeatureFlags() {
remoteConfig.value.onboarding_survey_enabled ??
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, true)
)
},
get huggingfaceModelImportEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.huggingface_model_import_enabled ??
api.getServerFeature(
ServerFeatureFlag.HUGGINGFACE_MODEL_IMPORT_ENABLED,
false
)
)
}
})

View File

@@ -211,11 +211,9 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
}
return Reflect.get(redirectedTarget, property, redirectedReceiver)
},
set(_t: IBaseWidget, property: string, value: unknown, receiver: object) {
set(_t: IBaseWidget, property: string, value: unknown) {
let redirectedTarget: object = backingWidget
let redirectedReceiver = receiver
if (property == 'value') redirectedReceiver = backingWidget
else if (property == 'computedHeight') {
if (property == 'computedHeight') {
if (overlay.widgetName.startsWith('$$') && linkedNode) {
updatePreviews(linkedNode)
}
@@ -228,9 +226,8 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
}
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
redirectedReceiver = overlay
}
return Reflect.set(redirectedTarget, property, value, redirectedReceiver)
return Reflect.set(redirectedTarget, property, value, redirectedTarget)
},
getPrototypeOf() {
return Reflect.getPrototypeOf(backingWidget)

View File

@@ -0,0 +1,48 @@
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.ImageCompare',
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'ImageCompare') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 350)])
const onExecuted = node.onExecuted
node.onExecuted = function (output: NodeExecutionOutput) {
onExecuted?.call(this, output)
const aImages = (output as Record<string, unknown>).a_images as
| Record<string, string>[]
| undefined
const bImages = (output as Record<string, unknown>).b_images as
| Record<string, string>[]
| undefined
const rand = app.getRandParam()
const beforeUrl =
aImages && aImages.length > 0
? api.apiURL(`/view?${new URLSearchParams(aImages[0])}${rand}`)
: ''
const afterUrl =
bImages && bImages.length > 0
? api.apiURL(`/view?${new URLSearchParams(bImages[0])}${rand}`)
: ''
const widget = node.widgets?.find((w) => w.type === 'imagecompare')
if (widget) {
widget.value = {
before: beforeUrl,
after: afterUrl
}
widget.callback?.(widget.value)
}
}
}
})

View File

@@ -9,6 +9,7 @@ import './electronAdapter'
import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './imageCompare'
import './load3d'
import './maskeditor'
import './nodeTemplates'

View File

@@ -192,8 +192,14 @@ export class DragAndScale {
bounds: ReadOnlyRect,
{ zoom = 0.75 }: { zoom?: number } = {}
): void {
const cw = this.element.width / window.devicePixelRatio
const ch = this.element.height / window.devicePixelRatio
//If element hasn't initialized (browser tab is in background)
//it has a size of 300x150 and a more reasonable default is used instead.
const [width, height] =
this.element.width === 300 && this.element.height === 150
? [1920, 1080]
: [this.element.width, this.element.height]
const cw = width / window.devicePixelRatio
const ch = height / window.devicePixelRatio
let targetScale = this.scale
if (zoom > 0) {

View File

@@ -284,6 +284,7 @@ export interface IBaseWidget<
/** Widget type (see {@link TWidgetType}) */
type: TType
value?: TValue
vueTrack?: () => void
/**
* Whether the widget value should be serialized on node serialization.

View File

@@ -648,6 +648,7 @@
"box": "Box"
},
"sideToolbar": {
"sidebar": "Sidebar",
"themeToggle": "Toggle Theme",
"helpCenter": "Help Center",
"logout": "Logout",
@@ -1416,6 +1417,7 @@
"latent": "latent",
"3d": "3d",
"ltxv": "ltxv",
"qwen": "qwen",
"sd3": "sd3",
"unet": "unet",
"sigmas": "sigmas",
@@ -1447,7 +1449,6 @@
"photomaker": "photomaker",
"PixVerse": "PixVerse",
"primitive": "primitive",
"qwen": "qwen",
"Recraft": "Recraft",
"edit_models": "edit_models",
"Rodin": "Rodin",
@@ -1607,6 +1608,9 @@
"errorMessage": "Failed to copy to clipboard",
"errorNotSupported": "Clipboard API not supported in your browser"
},
"imageCompare": {
"noImages": "No images to compare"
},
"load3d": {
"switchCamera": "Switch Camera",
"showGrid": "Show Grid",
@@ -2233,8 +2237,11 @@
"baseModels": "Base models",
"browseAssets": "Browse Assets",
"checkpoints": "Checkpoints",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295</a>",
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "Example:",
"civitaiLinkExampleUrl": "https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295",
"civitaiLinkLabel": "Civitai model {download} link",
"civitaiLinkLabelDownload": "download",
"civitaiLinkPlaceholder": "Paste link here",
"confirmModelDetails": "Confirm Model Details",
"connectionError": "Please check your connection and try again",
@@ -2252,8 +2259,11 @@
"filterBy": "Filter by",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"genericLinkPlaceholder": "Paste link here",
"jobId": "Job ID",
"loadingModels": "Loading {type}...",
"maxFileSize": "Max file size: {size}",
"maxFileSizeValue": "1 GB",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
@@ -2268,20 +2278,24 @@
"ownershipAll": "All",
"ownershipMyModels": "My models",
"ownershipPublicModels": "Public models",
"providerCivitai": "Civitai",
"providerHuggingFace": "Hugging Face",
"noValidSourceDetected": "No valid import source detected",
"selectFrameworks": "Select Frameworks",
"selectModelType": "Select model type",
"selectProjects": "Select Projects",
"sortAZ": "A-Z",
"sortBy": "Sort by",
"sortingType": "Sorting Type",
"sortPopular": "Popular",
"sortRecent": "Recent",
"sortZA": "Z-A",
"sortingType": "Sorting Type",
"tags": "Tags",
"tagsHelp": "Separate tags with commas",
"tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown",
"unsupportedUrlSource": "Only URLs from {sources} are supported",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",
@@ -2289,10 +2303,15 @@
"uploadingModel": "Importing model...",
"uploadModel": "Import",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com/models\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models</a> are supported at the moment",
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
"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:",
"uploadModelDescription3": "Max file size: {size}",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelGeneric": "Import a model",
"uploadModelHelpFooterText": "Need help finding the URLs? Click on a provider below to see a how-to video.",
"uploadModelHelpVideo": "Upload Model Help Video",
"uploadModelHowDoIFindThis": "How do I find this?",
"uploadSuccess": "Model imported successfully!",
@@ -2428,4 +2447,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -2359,6 +2359,28 @@
}
}
},
"EmptyQwenImageLayeredLatentImage": {
"display_name": "Empty Qwen Image Layered Latent",
"inputs": {
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"layers": {
"name": "layers"
},
"batch_size": {
"name": "batch_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"EmptySD3LatentImage": {
"display_name": "EmptySD3LatentImage",
"inputs": {
@@ -3161,13 +3183,16 @@
},
"outputs": {
"0": {
"name": "width"
"name": "width",
"tooltip": null
},
"1": {
"name": "height"
"name": "height",
"tooltip": null
},
"2": {
"name": "batch_size"
"name": "batch_size",
"tooltip": null
}
}
},
@@ -3703,6 +3728,11 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageBatch": {
@@ -3819,6 +3849,11 @@
"y": {
"name": "y"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageDeduplication": {
@@ -3849,6 +3884,11 @@
"flip_method": {
"name": "flip_method"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageFromBatch": {
@@ -3863,6 +3903,11 @@
"length": {
"name": "length"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageGrid": {
@@ -4002,6 +4047,11 @@
"rotation": {
"name": "rotation"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageScale": {
@@ -4050,6 +4100,11 @@
"largest_size": {
"name": "largest_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageScaleToTotalPixels": {
@@ -4098,7 +4153,7 @@
},
"ImageStitch": {
"display_name": "Image Stitch",
"description": "\nStitches image2 to image1 in the specified direction.\nIf image2 is not provided, returns image1 unchanged.\nOptional spacing can be added between images.\n",
"description": "Stitches image2 to image1 in the specified direction.\nIf image2 is not provided, returns image1 unchanged.\nOptional spacing can be added between images.",
"inputs": {
"image1": {
"name": "image1"
@@ -4118,6 +4173,11 @@
"image2": {
"name": "image2"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageToMask": {
@@ -4665,6 +4725,36 @@
}
}
},
"KlingMotionControl": {
"display_name": "Kling Motion Control",
"inputs": {
"prompt": {
"name": "prompt"
},
"reference_image": {
"name": "reference_image"
},
"reference_video": {
"name": "reference_video",
"tooltip": "Motion reference video used to drive movement/expression.\nDuration limits depend on character_orientation:\n - image: 310s (max 10s)\n - video: 330s (max 30s)"
},
"keep_original_sound": {
"name": "keep_original_sound"
},
"character_orientation": {
"name": "character_orientation",
"tooltip": "Controls where the character's facing/orientation comes from.\nvideo: movements, expressions, camera moves, and orientation follow the motion reference video (other details via prompt).\nimage: movements and expressions still follow the motion reference video, but the character orientation matches the reference image (camera/other details via prompt)."
},
"mode": {
"name": "mode"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingOmniProEditVideoNode": {
"display_name": "Kling Omni Edit Video (Pro)",
"description": "Edit an existing video with the latest model from Kling.",
@@ -6413,6 +6503,19 @@
}
}
},
"ManualSigmas": {
"display_name": "ManualSigmas",
"inputs": {
"sigmas": {
"name": "sigmas"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"MaskComposite": {
"display_name": "MaskComposite",
"inputs": {
@@ -10430,6 +10533,11 @@
"amount": {
"name": "amount"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RepeatLatentBatch": {
@@ -10517,6 +10625,11 @@
"interpolation": {
"name": "interpolation"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ResizeImagesByLongerEdge": {
@@ -13816,7 +13929,7 @@
},
"enhance_prompt": {
"name": "enhance_prompt",
"tooltip": "Whether to enhance the prompt with AI assistance"
"tooltip": "This parameter is deprecated and ignored."
},
"person_generation": {
"name": "person_generation",

View File

@@ -460,4 +460,4 @@
"pysssss_SnapToGrid": {
"name": "Always snap to grid"
}
}
}

View File

@@ -1,4 +1,40 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "检查更新"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "打开自定义节点文件夹"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "打开输入文件夹"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "打开日志文件夹"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "打开 extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "打开模型文件夹"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "打开输出文件夹"
},
"Comfy-Desktop_OpenDevTools": {
"label": "打开开发者工具"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "桌面端用户手册"
},
"Comfy-Desktop_Quit": {
"label": "退出"
},
"Comfy-Desktop_Reinstall": {
"label": "重装"
},
"Comfy-Desktop_Restart": {
"label": "重启"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "为所选节点开启 3D 浏览器Beta 版)"
},
@@ -221,6 +257,9 @@
"Comfy_ToggleHelpCenter": {
"label": "说明中心"
},
"Comfy_ToggleLinear": {
"label": "切换线性模式"
},
"Comfy_ToggleTheme": {
"label": "切换主题"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,9 +57,6 @@
"Comfy_ConfirmClear": {
"name": "清除工作流时需要确认"
},
"Comfy_DOMClippingEnabled": {
"name": "启用DOM元素裁剪启用可能会降低性能"
},
"Comfy_DevMode": {
"name": "启用开发模式选项API保存等"
},
@@ -70,6 +67,9 @@
"Comfy_DisableSliders": {
"name": "禁用节点组件滑块"
},
"Comfy_DOMClippingEnabled": {
"name": "启用DOM元素裁剪启用可能会降低性能"
},
"Comfy_EditAttention_Delta": {
"name": "Ctrl+上/下 精度"
},
@@ -79,6 +79,17 @@
"Comfy_EnableWorkflowViewRestore": {
"name": "在工作流中保存和恢复视图位置及缩放"
},
"Comfy_Execution_PreviewMethod": {
"name": "实时预览",
"options": {
"auto": "自动",
"default": "默认",
"latent2rgb": "latent2rgb",
"none": "无",
"taesd": "taesd"
},
"tooltip": "图像生成过程中实时预览。 \"默认\" 使用服务器 CLI 设置。"
},
"Comfy_FloatRoundingPrecision": {
"name": "浮点组件四舍五入的小数位数 [0 = 自动]。",
"tooltip": "(需要重新加载页面)"
@@ -100,15 +111,19 @@
"None": "无"
}
},
"Comfy_Graph_LiveSelection": {
"name": "实时选择",
"tooltip": "启用后,在框选拖动时实时选择/取消选择节点,类似于其他设计工具。"
},
"Comfy_Graph_ZoomSpeed": {
"name": "画布缩放速度"
},
"Comfy_GroupSelectedNodes_Padding": {
"name": "选定节点的组内边距"
},
"Comfy_Group_DoubleClickTitleToEdit": {
"name": "双击组标题以编辑"
},
"Comfy_GroupSelectedNodes_Padding": {
"name": "选定节点的组内边距"
},
"Comfy_LinkRelease_Action": {
"name": "释放连线时的操作",
"options": {
@@ -166,6 +181,15 @@
"name": "光照强度下限",
"tooltip": "设置3D场景允许的最小光照强度值。此项定义在调整任何3D控件照明时可设定的最低亮度。"
},
"Comfy_Load3D_PLYEngine": {
"name": "PLY 引擎",
"options": {
"fastply": "fastply",
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "选择加载 PLY 文件的引擎。 \"threejs\" 使用原生 Three.js PLY 加载器(最适合网格 PLY。 \"fastply\" 使用专用于 ASCII 点云的 PLY 文件加载器。 \"sparkjs\" 使用 Spark.js 加载 3D 高斯泼溅 PLY 文件。"
},
"Comfy_Load3D_ShowGrid": {
"name": "显示网格",
"tooltip": "默认显示网格开关"
@@ -193,6 +217,38 @@
},
"tooltip": "选择“文件名”以在模型列表中显示原始文件名的简化视图(不带目录和“.safetensors”后缀名。选择“标题”以显示可配置的模型元数据标题。"
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "在图像预览下方显示宽度×高度"
},
"Comfy_Node_AutoSnapLinkToSlot": {
"name": "连线自动吸附到节点接口",
"tooltip": "在节点上拖动连线时,连线会自动吸附到节点的可用输入接口。"
},
"Comfy_Node_BypassAllLinksOnDelete": {
"name": "删除节点时保留连线",
"tooltip": "删除节点时,尝试重新连接其所有输入和输出连线(类似于忽略节点)。"
},
"Comfy_Node_DoubleClickTitleToEdit": {
"name": "双击节点标题以编辑"
},
"Comfy_Node_MiddleClickRerouteNode": {
"name": "中键单击创建新的转接点"
},
"Comfy_Node_Opacity": {
"name": "节点不透明度"
},
"Comfy_Node_ShowDeprecated": {
"name": "在搜索中显示已弃用的节点",
"tooltip": "弃用节点在UI中默认隐藏但在工作流中仍然有效。"
},
"Comfy_Node_ShowExperimental": {
"name": "在搜索中显示实验性节点",
"tooltip": "实验节点在UI中标记为实验性可能在未来版本中发生重大变化或被移除。在生产工作流中谨慎使用。"
},
"Comfy_Node_SnapHighlightsNode": {
"name": "吸附高亮节点",
"tooltip": "在拖动连线经过具有可用输入接口的节点时,高亮显示该节点。"
},
"Comfy_NodeBadge_NodeIdBadgeMode": {
"name": "节点ID标签",
"options": {
@@ -245,38 +301,6 @@
"name": "节点建议数量",
"tooltip": "仅适用于 litegraph"
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "在图像预览下方显示宽度×高度"
},
"Comfy_Node_AutoSnapLinkToSlot": {
"name": "连线自动吸附到节点接口",
"tooltip": "在节点上拖动连线时,连线会自动吸附到节点的可用输入接口。"
},
"Comfy_Node_BypassAllLinksOnDelete": {
"name": "删除节点时保留连线",
"tooltip": "删除节点时,尝试重新连接其所有输入和输出连线(类似于忽略节点)。"
},
"Comfy_Node_DoubleClickTitleToEdit": {
"name": "双击节点标题以编辑"
},
"Comfy_Node_MiddleClickRerouteNode": {
"name": "中键单击创建新的转接点"
},
"Comfy_Node_Opacity": {
"name": "节点不透明度"
},
"Comfy_Node_ShowDeprecated": {
"name": "在搜索中显示已弃用的节点",
"tooltip": "弃用节点在UI中默认隐藏但在工作流中仍然有效。"
},
"Comfy_Node_ShowExperimental": {
"name": "在搜索中显示实验性节点",
"tooltip": "实验节点在UI中标记为实验性可能在未来版本中发生重大变化或被移除。在生产工作流中谨慎使用。"
},
"Comfy_Node_SnapHighlightsNode": {
"name": "吸附高亮节点",
"tooltip": "在拖动连线经过具有可用输入接口的节点时,高亮显示该节点。"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "显示版本更新",
"tooltip": "显示新模型和主要新功能的更新。"
@@ -300,14 +324,14 @@
"Comfy_PromptFilename": {
"name": "保存工作流时提示文件名"
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "批处理计数限制",
"tooltip": "单次添加到队列的最大任务数量"
},
"Comfy_Queue_MaxHistoryItems": {
"name": "队列历史大小",
"tooltip": "队列历史中显示的最大任务数量。"
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "批处理计数限制",
"tooltip": "单次添加到队列的最大任务数量"
},
"Comfy_Sidebar_Location": {
"name": "侧边栏位置",
"options": {
@@ -436,4 +460,4 @@
"pysssss_SnapToGrid": {
"name": "始终吸附到网格"
}
}
}

View File

@@ -4,7 +4,13 @@
>
<!-- Step 1: Enter URL -->
<UploadModelUrlInput
v-if="currentStep === 1"
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
v-model="wizardData.url"
:error="uploadError"
class="flex-1"
/>
<UploadModelUrlInputCivitai
v-else-if="currentStep === 1"
v-model="wizardData.url"
:error="uploadError"
/>
@@ -46,14 +52,17 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import UploadModelUrlInputCivitai from '@/platform/assets/components/UploadModelUrlInputCivitai.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()

View File

@@ -1,7 +1,11 @@
<template>
<div class="flex items-center gap-2 p-4 font-bold">
<img src="/assets/images/civitai.svg" class="size-4" />
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
<img
v-if="!flags.huggingfaceModelImportEnabled"
src="/assets/images/civitai.svg"
class="size-4"
/>
<span>{{ $t(titleKey) }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black"
>
@@ -9,3 +13,17 @@
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
const titleKey = computed(() => {
return flags.huggingfaceModelImportEnabled
? 'assetBrowser.uploadModelGeneric'
: 'assetBrowser.uploadModelFromCivitai'
})
</script>

View File

@@ -1,12 +1,34 @@
<template>
<div class="flex justify-end gap-2 w-full">
<div
v-if="currentStep === 1 && flags.huggingfaceModelImportEnabled"
class="mr-auto flex items-center gap-2"
>
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
<Button
variant="muted-textonly"
size="sm"
data-attr="upload-model-step1-help-civitai"
@click="showCivitaiHelp = true"
>
{{ $t('assetBrowser.providerCivitai') }}
</Button>
<Button
variant="muted-textonly"
size="sm"
data-attr="upload-model-step1-help-huggingface"
@click="showHuggingFaceHelp = true"
>
{{ $t('assetBrowser.providerHuggingFace') }}
</Button>
</div>
<Button
v-if="currentStep === 1"
v-else-if="currentStep === 1"
variant="muted-textonly"
size="lg"
class="mr-auto underline"
data-attr="upload-model-step1-help-link"
@click="showVideoHelp = true"
@click="showCivitaiHelp = true"
>
<i class="icon-[lucide--circle-question-mark]" />
<span>{{ $t('assetBrowser.uploadModelHowDoIFindThis') }}</span>
@@ -67,10 +89,15 @@
{{ $t('assetBrowser.finish') }}
</Button>
<VideoHelpDialog
v-model="showVideoHelp"
v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
:aria-label="$t('assetBrowser.uploadModelHelpVideo')"
/>
<VideoHelpDialog
v-model="showHuggingFaceHelp"
video-url="https://media.comfy.org/byom/huggingfacehowto.mp4"
:aria-label="$t('assetBrowser.uploadModelHelpVideo')"
/>
</div>
</template>
@@ -78,9 +105,13 @@
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
const showVideoHelp = ref(false)
const { flags } = useFeatureFlags()
const showCivitaiHelp = ref(false)
const showHuggingFaceHelp = ref(false)
defineProps<{
currentStep: number

View File

@@ -1,28 +1,74 @@
<template>
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0">
<li v-html="$t('assetBrowser.uploadModelDescription2')" />
<li v-html="$t('assetBrowser.uploadModelDescription3')" />
</ul>
<div class="flex flex-col justify-between h-full gap-6 text-sm">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<p class="m-0 text-foreground">
{{ $t('assetBrowser.uploadModelDescription1Generic') }}
</p>
<div class="m-0">
<p class="m-0 text-muted-foreground">
{{ $t('assetBrowser.uploadModelDescription2Generic') }}
</p>
<span class="inline-flex items-center gap-1 flex-wrap mt-2">
<span class="inline-flex items-center gap-1">
<img
:src="civitaiIcon"
:alt="$t('assetBrowser.providerCivitai')"
class="w-4 h-4"
/>
<a
:href="civitaiUrl"
target="_blank"
rel="noopener noreferrer"
class="text-muted underline"
>
{{ $t('assetBrowser.providerCivitai') }}</a
><span>,</span>
</span>
<span class="inline-flex items-center gap-1">
<img
:src="huggingFaceIcon"
:alt="$t('assetBrowser.providerHuggingFace')"
class="w-4 h-4"
/>
<a
:href="huggingFaceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-muted underline"
>
{{ $t('assetBrowser.providerHuggingFace') }}
</a>
</span>
</span>
</div>
</div>
<div class="flex flex-col gap-2">
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.genericLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-foreground">
<i18n-t keypath="assetBrowser.maxFileSize" tag="span">
<template #size>
<span class="font-bold italic">{{
$t('assetBrowser.maxFileSizeValue')
}}</span>
</template>
</i18n-t>
</p>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="mb-0" v-html="$t('assetBrowser.civitaiLinkLabel')"> </label>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else v-html="$t('assetBrowser.civitaiLinkExample')"></p>
<div class="text-sm text-muted">
{{ $t('assetBrowser.uploadModelHelpFooterText') }}
</div>
</div>
</template>
@@ -44,4 +90,9 @@ const url = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
})
const civitaiIcon = '/assets/images/civitai.svg'
const civitaiUrl = 'https://civitai.com/models'
const huggingFaceIcon = '/assets/images/hf-logo.svg'
const huggingFaceUrl = 'https://huggingface.co'
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0">
<li>
<i18n-t keypath="assetBrowser.uploadModelDescription2" tag="span">
<template #link>
<a
href="https://civitai.com/models"
target="_blank"
class="text-muted-foreground"
>
{{ $t('assetBrowser.uploadModelDescription2Link') }}
</a>
</template>
</i18n-t>
</li>
<li>
<i18n-t keypath="assetBrowser.uploadModelDescription3" tag="span">
<template #size>
<span class="font-bold italic">{{
$t('assetBrowser.maxFileSizeValue')
}}</span>
</template>
</i18n-t>
</li>
</ul>
</div>
<div class="flex flex-col gap-2">
<i18n-t keypath="assetBrowser.civitaiLinkLabel" tag="label" class="mb-0">
<template #download>
<span class="font-bold italic">{{
$t('assetBrowser.civitaiLinkLabelDownload')
}}</span>
</template>
</i18n-t>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full bg-secondary-background border-0 p-4"
data-attr="upload-model-step1-url-input"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<i18n-t
v-else
keypath="assetBrowser.civitaiLinkExample"
tag="p"
class="text-xs"
>
<template #example>
<strong>{{ $t('assetBrowser.civitaiLinkExampleStrong') }}</strong>
</template>
<template #link>
<a
href="https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295"
target="_blank"
class="text-muted-foreground"
>
{{ $t('assetBrowser.civitaiLinkExampleUrl') }}
</a>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
defineProps<{
error?: string
}>()
const url = defineModel<string>({ required: true })
</script>

View File

@@ -50,10 +50,12 @@ export const useModelTypes = createSharedComposable(() => {
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders()
return response.map((folder) => ({
name: formatDisplayName(folder.name),
value: folder.name
}))
return response
.map((folder) => ({
name: formatDisplayName(folder.name),
value: folder.name
}))
.sort((a, b) => a.name.localeCompare(b.name))
},
[] as ModelTypeOption[],
{

View File

@@ -1,9 +1,15 @@
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
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 { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -21,8 +27,10 @@ interface ModelTypeOption {
}
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const { t } = useI18n()
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const { flags } = useFeatureFlags()
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
@@ -37,6 +45,20 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const selectedModelType = ref<string>()
// Available import sources
const importSources: ImportSource[] = flags.huggingfaceModelImportEnabled
? [civitaiImportSource, huggingfaceImportSource]
: [civitaiImportSource]
// Detected import source based on URL
const detectedSource = computed(() => {
const url = wizardData.value.url.trim()
if (!url) return null
return (
importSources.find((source) => validateSourceUrl(url, source)) ?? null
)
})
// Clear error when URL changes
watch(
() => wizardData.value.url,
@@ -54,15 +76,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
return !!selectedModelType.value
})
function isCivitaiUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase()
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
} catch {
return false
}
}
async function fetchMetadata() {
if (!canFetchMetadata.value) return
@@ -75,17 +88,36 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
wizardData.value.url = cleanedUrl
if (!isCivitaiUrl(wizardData.value.url)) {
uploadError.value = st(
'assetBrowser.onlyCivitaiUrlsSupported',
'Only Civitai URLs are supported'
)
// Validate URL belongs to a supported import source
const source = detectedSource.value
if (!source) {
const supportedSources = importSources.map((s) => s.name).join(', ')
uploadError.value = t('assetBrowser.unsupportedUrlSource', {
sources: supportedSources
})
return
}
isFetchingMetadata.value = true
try {
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
// Decode URL-encoded filenames (e.g., Chinese characters)
if (metadata.filename) {
try {
metadata.filename = decodeURIComponent(metadata.filename)
} catch {
// Keep original if decoding fails
}
}
if (metadata.name) {
try {
metadata.name = decodeURIComponent(metadata.name)
} catch {
// Keep original if decoding fails
}
}
wizardData.value.metadata = metadata
// Pre-fill name from metadata
@@ -125,6 +157,14 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
async function uploadModel() {
if (!canUploadModel.value) return
// 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')
return false
}
isUploading.value = true
uploadStatus.value = 'uploading'
@@ -170,7 +210,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
name: filename,
tags,
user_metadata: {
source: 'civitai',
source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
},
@@ -224,6 +264,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
// Computed
canFetchMetadata,
canUploadModel,
detectedSource,
// Actions
fetchMetadata,

View File

@@ -0,0 +1,10 @@
import type { ImportSource } from '@/platform/assets/types/importSource'
/**
* Civitai model import source configuration
*/
export const civitaiImportSource: ImportSource = {
type: 'civitai',
name: 'Civitai',
hostnames: ['civitai.com']
}

View File

@@ -0,0 +1,10 @@
import type { ImportSource } from '@/platform/assets/types/importSource'
/**
* Hugging Face model import source configuration
*/
export const huggingfaceImportSource: ImportSource = {
type: 'huggingface',
name: 'Hugging Face',
hostnames: ['huggingface.co']
}

View File

@@ -0,0 +1,24 @@
/**
* Supported model import sources
*/
type ImportSourceType = 'civitai' | 'huggingface'
/**
* Configuration for a model import source
*/
export interface ImportSource {
/**
* Unique identifier for this import source
*/
readonly type: ImportSourceType
/**
* Display name for the source
*/
readonly name: string
/**
* Hostname(s) that identify this source
*/
readonly hostnames: readonly string[]
}

View File

@@ -0,0 +1,15 @@
import type { ImportSource } from '@/platform/assets/types/importSource'
/**
* Check if a URL belongs to a specific import source
*/
export function validateSourceUrl(url: string, source: ImportSource): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase()
return source.hostnames.some(
(h) => hostname === h || hostname.endsWith(`.${h}`)
)
} catch {
return false
}
}

View File

@@ -333,7 +333,7 @@ const { n } = useI18n()
const { getAuthHeader } = useFirebaseAuthStore()
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
useSubscription()
const { reportError } = useFirebaseAuthActions()
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
@@ -443,9 +443,15 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
loadingTier.value = tierKey
try {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
window.open(response.checkout_url, '_blank')
if (isActiveSubscription.value) {
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
await accessBillingPortal(checkoutTier)
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
window.open(response.checkout_url, '_blank')
}
}
} finally {
isLoading.value = false

View File

@@ -38,4 +38,5 @@ export type RemoteConfig = {
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
huggingface_model_import_enabled?: boolean
}

View File

@@ -8,7 +8,6 @@ import { onUnmounted, ref } from 'vue'
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
/**
* Composable for syncing LiteGraph with the Layout system
@@ -44,13 +43,15 @@ export function useLayoutSync() {
liteNode.pos[1] = layout.position.y
}
const targetHeight = addNodeTitleHeight(layout.size.height)
// Note: layout.size.height is the content height without title.
// LiteGraph's measure() will add titleHeight to get boundingRect.
// Do NOT use addNodeTitleHeight here - that would double-count the title.
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== targetHeight
liteNode.size[1] !== layout.size.height
) {
// Use setSize() to trigger onResize callback
liteNode.setSize([layout.size.width, targetHeight])
liteNode.setSize([layout.size.width, layout.size.height])
}
}

View File

@@ -2,6 +2,3 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
export const removeNodeTitleHeight = (height: number) =>
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
export const addNodeTitleHeight = (height: number) =>
height + LiteGraph.NODE_TITLE_HEIGHT

View File

@@ -43,7 +43,12 @@
)
"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
{{
slotData.label ||
slotData.localized_name ||
slotData.name ||
`Input ${index}`
}}
</span>
</div>
</div>

View File

@@ -1,6 +1,4 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ImageCompare from 'primevue/imagecompare'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -25,8 +23,9 @@ describe('WidgetImageCompare Display', () => {
) => {
return mount(WidgetImageCompare, {
global: {
plugins: [PrimeVue],
components: { ImageCompare }
mocks: {
$t: (key: string) => key
}
},
props: {
widget,
@@ -36,7 +35,7 @@ describe('WidgetImageCompare Display', () => {
}
describe('Component Rendering', () => {
it('renders imagecompare component with proper structure and styling', () => {
it('renders with proper structure and styling when images are provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -44,21 +43,15 @@ describe('WidgetImageCompare Display', () => {
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
// Component exists
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
// Renders both images with correct URLs
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
// Images have proper styling classes
// In the new implementation: after image is first (background), before image is second (overlay)
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
images.forEach((img) => {
expect(img.classes()).toContain('object-cover')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
expect(img.classes()).toContain('object-contain')
})
})
})
@@ -74,8 +67,9 @@ describe('WidgetImageCompare Display', () => {
}
const customWrapper = mountComponent(createMockWidget(customAltValue))
const customImages = customWrapper.findAll('img')
expect(customImages[0].attributes('alt')).toBe('Original design')
expect(customImages[1].attributes('alt')).toBe('Updated design')
// DOM order: [after, before]
expect(customImages[0].attributes('alt')).toBe('Updated design')
expect(customImages[1].attributes('alt')).toBe('Original design')
// Test default alt text
const defaultAltValue: ImageCompareValue = {
@@ -84,8 +78,8 @@ describe('WidgetImageCompare Display', () => {
}
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
const defaultImages = defaultWrapper.findAll('img')
expect(defaultImages[0].attributes('alt')).toBe('Before image')
expect(defaultImages[1].attributes('alt')).toBe('After image')
expect(defaultImages[0].attributes('alt')).toBe('After image')
expect(defaultImages[1].attributes('alt')).toBe('Before image')
// Test empty string alt text (falls back to default)
const emptyAltValue: ImageCompareValue = {
@@ -96,29 +90,36 @@ describe('WidgetImageCompare Display', () => {
}
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
const emptyImages = emptyWrapper.findAll('img')
expect(emptyImages[0].attributes('alt')).toBe('Before image')
expect(emptyImages[1].attributes('alt')).toBe('After image')
expect(emptyImages[0].attributes('alt')).toBe('After image')
expect(emptyImages[1].attributes('alt')).toBe('Before image')
})
it('handles missing and partial image URLs gracefully', () => {
// Missing URLs
const missingValue: ImageCompareValue = { before: '', after: '' }
const missingWrapper = mountComponent(createMockWidget(missingValue))
const missingImages = missingWrapper.findAll('img')
expect(missingImages[0].attributes('src')).toBe('')
expect(missingImages[1].attributes('src')).toBe('')
// Partial URLs
const partialValue: ImageCompareValue = {
it('handles partial image URLs gracefully', () => {
// Only before image provided
const beforeOnlyValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: ''
}
const partialWrapper = mountComponent(createMockWidget(partialValue))
const partialImages = partialWrapper.findAll('img')
expect(partialImages[0].attributes('src')).toBe(
const beforeOnlyWrapper = mountComponent(
createMockWidget(beforeOnlyValue)
)
const beforeOnlyImages = beforeOnlyWrapper.findAll('img')
expect(beforeOnlyImages).toHaveLength(1)
expect(beforeOnlyImages[0].attributes('src')).toBe(
'https://example.com/before.jpg'
)
expect(partialImages[1].attributes('src')).toBe('')
// Only after image provided
const afterOnlyValue: ImageCompareValue = {
before: '',
after: 'https://example.com/after.jpg'
}
const afterOnlyWrapper = mountComponent(createMockWidget(afterOnlyValue))
const afterOnlyImages = afterOnlyWrapper.findAll('img')
expect(afterOnlyImages).toHaveLength(1)
expect(afterOnlyImages[0].attributes('src')).toBe(
'https://example.com/after.jpg'
)
})
})
@@ -129,75 +130,14 @@ describe('WidgetImageCompare Display', () => {
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images).toHaveLength(1)
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
expect(images[1].attributes('src')).toBe('')
})
it('uses default alt text for string values', () => {
const value = 'https://example.com/single.jpg'
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
})
})
describe('Widget Options Handling', () => {
it('passes through accessibility options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
tabindex: 1,
ariaLabel: 'Compare images',
ariaLabelledby: 'compare-label'
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(1)
expect(imageCompare.props('ariaLabel')).toBe('Compare images')
expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
})
it('uses default tabindex when not provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(0)
})
it('passes through PrimeVue specific options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
unstyled: true,
pt: { root: { class: 'custom-class' } },
ptOptions: { mergeSections: true }
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('unstyled')).toBe(true)
expect(imageCompare.props('pt')).toEqual({
root: { class: 'custom-class' }
})
expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
})
})
describe('Readonly Mode', () => {
it('renders normally in readonly mode (no interaction restrictions)', () => {
it('renders normally in readonly mode', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -205,45 +145,39 @@ describe('WidgetImageCompare Display', () => {
const widget = createMockWidget(value)
const wrapper = mountComponent(widget, true)
// ImageCompare is display-only, readonly doesn't affect rendering
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('handles null or undefined widget value', () => {
it('shows no images message when widget value is empty string', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('handles empty object value', () => {
it('shows no images message when both URLs are empty', () => {
const value: ImageCompareValue = { before: '', after: '' }
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('shows no images message for empty object value', () => {
const value: ImageCompareValue = {} as ImageCompareValue
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
})
it('handles malformed object value', () => {
const value = { randomProp: 'test', before: '', after: '' }
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('handles special content - long URLs, special characters, and long alt text', () => {
@@ -290,7 +224,7 @@ describe('WidgetImageCompare Display', () => {
})
describe('Template Structure', () => {
it('correctly assigns images to left and right template slots', () => {
it('correctly renders after image as background and before image as overlay', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
@@ -299,10 +233,11 @@ describe('WidgetImageCompare Display', () => {
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
// First image (before) should be in left template slot
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
// Second image (after) should be in right template slot
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
// After image is rendered first as background
expect(images[0].attributes('src')).toBe('https://example.com/after.jpg')
// Before image is rendered second as overlay with clipPath
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
expect(images[1].classes()).toContain('absolute')
})
})
@@ -333,4 +268,27 @@ describe('WidgetImageCompare Display', () => {
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
})
})
describe('Slider Element', () => {
it('renders slider divider when images are present', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const slider = wrapper.find('[role="presentation"]')
expect(slider.exists()).toBe(true)
expect(slider.classes()).toContain('bg-white')
})
it('does not render slider when no images', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const slider = wrapper.find('[role="presentation"]')
expect(slider.exists()).toBe(false)
})
})
})

View File

@@ -1,32 +1,39 @@
<template>
<ImageCompare
:tabindex="widget.options?.tabindex ?? 0"
:aria-label="widget.options?.ariaLabel"
:aria-labelledby="widget.options?.ariaLabelledby"
:pt="widget.options?.pt"
:pt-options="widget.options?.ptOptions"
:unstyled="widget.options?.unstyled"
>
<template #left>
<img
:src="beforeImage"
:alt="beforeAlt"
class="h-full w-full object-cover"
/>
</template>
<template #right>
<div ref="containerRef" class="relative size-full min-h-32 overflow-hidden">
<div v-if="beforeImage || afterImage">
<img
v-if="afterImage"
:src="afterImage"
:alt="afterAlt"
class="h-full w-full object-cover"
draggable="false"
class="size-full object-contain"
/>
</template>
</ImageCompare>
<img
v-if="beforeImage"
:src="beforeImage"
:alt="beforeAlt"
draggable="false"
class="absolute inset-0 size-full object-contain"
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
/>
<div
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 bg-white shadow-md"
:style="{ left: `${sliderPosition}%` }"
role="presentation"
/>
</div>
<div v-else class="flex size-full items-center justify-center">
{{ $t('imageCompare.noImages') }}
</div>
</div>
</template>
<script setup lang="ts">
import ImageCompare from 'primevue/imagecompare'
import { computed } from 'vue'
import { useMouseInElement } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -43,6 +50,17 @@ const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
}>()
const containerRef = ref<HTMLElement | null>(null)
const sliderPosition = ref(50)
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
watch([elementX, elementWidth, isOutside], ([x, width, outside]) => {
if (!outside && width > 0) {
sliderPosition.value = Math.max(0, Math.min(100, (x / width) * 100))
}
})
const beforeImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.before || ''

View File

@@ -41,7 +41,11 @@ import {
isComboInputSpecV1,
isComboInputSpecV2
} from '@/schemas/nodeDefSchema'
import { type BaseDOMWidget, DOMWidgetImpl } from '@/scripts/domWidget'
import {
type BaseDOMWidget,
ComponentWidgetImpl,
DOMWidgetImpl
} from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useExtensionService } from '@/services/extensionService'
@@ -812,7 +816,10 @@ export class ComfyApp {
> = Object.fromEntries(
newGraph.nodes
.flatMap((node) => node.widgets ?? [])
.filter((w) => w instanceof DOMWidgetImpl)
.filter(
(w) =>
w instanceof DOMWidgetImpl || w instanceof ComponentWidgetImpl
)
.map((w) => [w.id, w])
)

View File

@@ -42,6 +42,11 @@ type AccessBillingPortalResponse =
operations['AccessBillingPortal']['responses']['200']['content']['application/json']
type AccessBillingPortalReqBody =
operations['AccessBillingPortal']['requestBody']
export type BillingPortalTargetTier = NonNullable<
NonNullable<
NonNullable<AccessBillingPortalReqBody>['content']
>['application/json']
>['target_tier']
export class FirebaseAuthStoreError extends Error {
constructor(message: string) {
@@ -409,13 +414,15 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
executeAuthAction((_) => addCredits(requestBodyContent))
const accessBillingPortal = async (
requestBody?: AccessBillingPortalReqBody
targetTier?: BillingPortalTargetTier
): Promise<AccessBillingPortalResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const requestBody = targetTier ? { target_tier: targetTier } : undefined
const response = await fetch(buildApiUrl('/customers/billing'), {
method: 'POST',
headers: {

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex w-[552px] flex-col">
<div class="flex w-138 flex-col">
<ContentDivider :width="1" />
<div class="flex h-full w-full flex-col gap-2 px-4 py-6">
<!-- Description -->
@@ -37,7 +37,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-muted"
class="bg-transparent text-muted"
/>
</div>
</div>
@@ -45,12 +45,12 @@
<div
v-if="importFailedExpanded"
data-testid="conflict-dialog-panel-expanded"
class="flex max-h-[142px] scrollbar-hide flex-col gap-2.5 overflow-y-auto px-4 py-2"
class="flex max-h-35.5 scrollbar-hide flex-col gap-2.5 overflow-y-auto px-4 py-2"
>
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">
{{ packageName }}
@@ -60,7 +60,10 @@
</div>
</div>
<!-- Conflict List Wrapper -->
<div class="flex min-h-8 w-full flex-col rounded-lg bg-base-background">
<div
v-if="allConflictDetails.length > 0"
class="flex min-h-8 w-full flex-col rounded-lg bg-base-background"
>
<div
data-testid="conflict-dialog-panel-toggle"
class="flex h-8 w-full items-center justify-between gap-2 pl-4"
@@ -82,7 +85,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-muted"
class="bg-transparent text-muted"
/>
</div>
</div>
@@ -95,7 +98,7 @@
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">{{
getConflictMessage(conflict, t)
@@ -105,7 +108,10 @@
</div>
</div>
<!-- Extension List Wrapper -->
<div class="flex min-h-8 w-full flex-col rounded-lg bg-base-background">
<div
v-if="conflictData.length > 0"
class="flex min-h-8 w-full flex-col rounded-lg bg-base-background"
>
<div
data-testid="conflict-dialog-panel-toggle"
class="flex h-8 w-full items-center justify-between gap-2 pl-4"
@@ -127,7 +133,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-muted"
class="bg-transparent text-muted"
/>
</div>
</div>
@@ -135,12 +141,12 @@
<div
v-if="extensionsExpanded"
data-testid="conflict-dialog-panel-expanded"
class="flex max-h-[142px] scrollbar-hide flex-col gap-2.5 overflow-y-auto px-4 py-2"
class="flex max-h-35.5 scrollbar-hide flex-col gap-2.5 overflow-y-auto px-4 py-2"
>
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-muted">
{{ conflictResult.package_name }}

View File

@@ -33,6 +33,8 @@ const mockNodePack = {
const mockIsPackEnabled = vi.fn()
const mockEnablePack = vi.fn().mockResolvedValue(undefined)
const mockDisablePack = vi.fn().mockResolvedValue(undefined)
const mockGetConflictsForPackageByID = vi.fn()
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackEnabled: mockIsPackEnabled,
@@ -42,6 +44,12 @@ vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
}))
}))
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore', () => ({
useConflictDetectionStore: vi.fn(() => ({
getConflictsForPackageByID: mockGetConflictsForPackageByID
}))
}))
describe('PackEnableToggle', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -163,4 +171,41 @@ describe('PackEnableToggle', () => {
await nextTick()
expect(wrapper.findComponent(ToggleSwitch).props('disabled')).toBe(false)
})
describe('conflict warning icon', () => {
it('should show warning icon when package has conflicts', () => {
mockGetConflictsForPackageByID.mockReturnValue({
package_id: 'test-pack',
package_name: 'Test Pack',
has_conflict: true,
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: 'error message'
}
],
is_compatible: false
})
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
// Check if warning icon exists
const warningIcon = wrapper.find('.pi-exclamation-triangle')
expect(warningIcon.exists()).toBe(true)
expect(warningIcon.classes()).toContain('text-yellow-500')
})
it('should not show warning icon when package has no conflicts', () => {
mockGetConflictsForPackageByID.mockReturnValue(null)
mockIsPackEnabled.mockReturnValue(true)
const wrapper = mountComponent()
// Check if warning icon does not exist
const warningIcon = wrapper.find('.pi-exclamation-triangle')
expect(warningIcon.exists()).toBe(false)
})
})
})

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex items-center gap-2">
<div
v-if="hasConflict"
v-if="packageConflict?.has_conflict"
v-tooltip="{
value: $t('manager.conflicts.warningTooltip'),
showDelay: 300
@@ -43,9 +43,8 @@ import type { components as ManagerComponents } from '@/workbench/extensions/man
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack, hasConflict } = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { t } = useI18n()
@@ -68,14 +67,14 @@ const version = computed(() => {
)
})
const packageConflict = computed(() =>
getConflictsForPackageByID(nodePack.id || '')
)
const packageConflict = computed(() => {
if (!nodePack.id) return undefined
return getConflictsForPackageByID(nodePack.id)
})
const canToggleDirectly = computed(() => {
return !(
hasConflict &&
!acknowledgmentState.value.modal_dismissed &&
packageConflict.value
packageConflict.value?.has_conflict &&
!acknowledgmentState.value.modal_dismissed
)
})

View File

@@ -0,0 +1,177 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { IsInstallingKey } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import PackCardFooter from './PackCardFooter.vue'
// Mock the child components
vi.mock(
'@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue',
() => ({
default: { template: '<div data-testid="pack-install-button"></div>' }
})
)
vi.mock(
'@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue',
() => ({
default: { template: '<div data-testid="pack-enable-toggle"></div>' }
})
)
// Mock composables
const mockIsPackInstalled = vi.fn()
const mockCheckNodeCompatibility = vi.fn()
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackInstalled: mockIsPackInstalled
}))
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: vi.fn(() => ({
checkNodeCompatibility: mockCheckNodeCompatibility
}))
})
)
// Remove the mock for injection key since we're importing it directly
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
downloads: 1000
}
describe('PackCardFooter', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPackInstalled.mockReset()
mockCheckNodeCompatibility.mockReset()
})
const mountComponent = (props = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(PackCardFooter, {
props: {
nodePack: mockNodePack,
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
provide: {
[IsInstallingKey]: ref(false)
}
}
})
}
describe('component rendering', () => {
it('shows download count when available', () => {
mockIsPackInstalled.mockReturnValue(false)
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
expect(wrapper.text()).toContain('1,000')
})
it('shows install button for uninstalled packages', () => {
mockIsPackInstalled.mockReturnValue(false)
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="pack-install-button"]').exists()).toBe(
true
)
expect(wrapper.find('[data-testid="pack-enable-toggle"]').exists()).toBe(
false
)
})
it('shows enable toggle for installed packages', () => {
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="pack-enable-toggle"]').exists()).toBe(
true
)
expect(wrapper.find('[data-testid="pack-install-button"]').exists()).toBe(
false
)
})
})
describe('conflict detection for uninstalled packages', () => {
it('passes conflict info to install button when conflicts exist', () => {
mockIsPackInstalled.mockReturnValue(false)
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: true,
conflicts: [
{
type: 'os_conflict',
current_value: 'windows',
required_value: 'linux'
}
]
})
const wrapper = mountComponent()
const installButton = wrapper.find('[data-testid="pack-install-button"]')
expect(installButton.exists()).toBe(true)
// The install button should receive has-conflict prop as true
expect(installButton.attributes()).toHaveProperty('has-conflict')
})
it('does not pass conflict info when no conflicts exist', () => {
mockIsPackInstalled.mockReturnValue(false)
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
const installButton = wrapper.find('[data-testid="pack-install-button"]')
expect(installButton.exists()).toBe(true)
// The install button should receive has-conflict prop as false
expect(installButton.attributes()['has-conflict']).toBe('false')
})
})
describe('installed packages', () => {
it('does not pass has-conflict prop to enable toggle', () => {
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountComponent()
const enableToggle = wrapper.find('[data-testid="pack-enable-toggle"]')
expect(enableToggle.exists()).toBe(true)
// The enable toggle should not receive has-conflict prop (removed in our fix)
expect(enableToggle.attributes()).not.toHaveProperty('has-conflict')
})
})
})

View File

@@ -13,11 +13,7 @@
:has-conflict="hasConflicts"
:conflict-info="conflictInfo"
/>
<PackEnableToggle
v-else
:has-conflict="hasConflicts"
:node-pack="nodePack"
/>
<PackEnableToggle v-else :node-pack="nodePack" />
</div>
</template>

View File

@@ -7,6 +7,7 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
@@ -87,9 +88,11 @@ export function useConflictDetection() {
try {
// Get system stats from store (primary source of system information)
// Wait for systemStats to be initialized if not already
const { systemStats, isInitialized: systemStatsInitialized } =
useSystemStatsStore()
await until(systemStatsInitialized)
const systemStatsStore = useSystemStatsStore()
const { systemStats } = systemStatsStore
// Wait for initialization using the store's isInitialized property (correct reactive way)
await until(() => systemStatsStore.isInitialized).toBe(true)
const frontendVersion = getFrontendVersion()
@@ -548,9 +551,11 @@ export function useConflictDetection() {
*/
async function initializeConflictDetection(): Promise<void> {
try {
// Check if manager is new Manager before proceeding
const { useManagerState } =
await import('@/workbench/extensions/manager/composables/useManagerState')
// First, wait for systemStats to be initialized
const systemStatsStore = useSystemStatsStore()
await until(() => systemStatsStore.isInitialized).toBe(true)
// Now check if manager is new Manager
const managerState = useManagerState()
if (!managerState.isNewManagerUI.value) {

View File

@@ -137,9 +137,9 @@ describe('NodeConflictDialogContent', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('Extensions at Risk')
// When there are no conflicts, the conflict sections should not be rendered
expect(wrapper.text()).not.toContain('Conflicts')
expect(wrapper.text()).not.toContain('Extensions at Risk')
expect(wrapper.find('[class*="Import Failed Extensions"]').exists()).toBe(
false
)
@@ -364,9 +364,9 @@ describe('NodeConflictDialogContent', () => {
mockConflictData.value = []
const wrapper = createWrapper()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).toContain('Extensions at Risk')
// When there are no conflicts, none of the sections should be visible
expect(wrapper.text()).not.toContain('Conflicts')
expect(wrapper.text()).not.toContain('Extensions at Risk')
// Import failed section should not be visible when there are no import failures
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})

View File

@@ -14,6 +14,17 @@ import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { checkVersionCompatibility } from '@/workbench/extensions/manager/utils/versionUtil'
// Mock @vueuse/core until function
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
until: vi.fn(() => ({
toBe: vi.fn(() => Promise.resolve())
}))
}
})
// Mock dependencies
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()
@@ -159,6 +170,7 @@ describe('useConflictDetection', () => {
clearConflicts: vi.fn()
} as unknown as ReturnType<typeof useConflictDetectionStore>
const mockIsInitialized = ref(true)
const mockSystemStatsStore = {
systemStats: {
system: {
@@ -171,7 +183,7 @@ describe('useConflictDetection', () => {
'3.11.0 (main, Oct 13 2023, 09:34:16) [Clang 15.0.0 (clang-1500.0.40.1)]',
pytorch_version: '2.1.0',
embedded_python: false,
argv: []
argv: ['--enable-manager']
},
devices: [
{
@@ -185,7 +197,7 @@ describe('useConflictDetection', () => {
}
]
},
isInitialized: ref(true),
isInitialized: mockIsInitialized,
$state: {} as never,
$patch: vi.fn(),
$reset: vi.fn(),

View File

@@ -0,0 +1,237 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.vue'
const mockIsActiveSubscription = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>(null)
const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
})
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal,
reportError: mockReportError
})
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: vi.fn(
(fn, errorHandler) =>
async (...args: unknown[]) => {
try {
return await fn(...args)
} catch (error) {
if (errorHandler) {
errorHandler(error)
}
throw error
}
}
)
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getAuthHeader: mockGetAuthHeader
}),
FirebaseAuthStoreError: class extends Error {}
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
global.fetch = vi.fn()
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
yearly: 'Yearly',
monthly: 'Monthly',
mostPopular: 'Most Popular',
usdPerMonth: '/ month',
billedYearly: 'Billed yearly ({total})',
billedMonthly: 'Billed monthly',
currentPlan: 'Current Plan',
subscribeTo: 'Subscribe to {plan}',
changeTo: 'Change to {plan}',
maxDuration: {
standard: '30 min',
creator: '30 min',
pro: '1 hr'
},
tiers: {
standard: { name: 'Standard' },
creator: { name: 'Creator' },
pro: { name: 'Pro' }
},
benefits: {
monthlyCredits: '{credits} monthly credits',
maxDuration: '{duration} max duration',
gpu: 'RTX 6000 Pro GPU',
addCredits: 'Add more credits anytime',
customLoRAs: 'Import custom LoRAs'
}
}
}
}
})
function createWrapper() {
return mount(PricingTable, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
SelectButton: {
template: '<div><slot /></div>',
props: ['modelValue', 'options'],
emits: ['update:modelValue']
},
Popover: { template: '<div><slot /></div>' },
Button: {
template:
'<button @click="$emit(\'click\')" :disabled="disabled" :data-tier="dataTier">{{ label }}</button>',
props: ['loading', 'label', 'severity', 'disabled', 'dataTier', 'pt'],
emits: ['click']
}
}
}
})
}
describe('PricingTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsActiveSubscription.value = false
mockSubscriptionTier.value = null
mockIsYearlySubscription.value = false
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
} as Response)
})
describe('billing portal deep linking', () => {
it('should call accessBillingPortal with yearly tier suffix when billing cycle is yearly (default)', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
const wrapper = createWrapper()
await flushPromises()
const creatorButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Creator'))
expect(creatorButton).toBeDefined()
await creatorButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
})
it('should call accessBillingPortal with different tiers correctly', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
const wrapper = createWrapper()
await flushPromises()
const proButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Pro'))
await proButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('pro-yearly')
})
it('should not call accessBillingPortal when clicking current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
const wrapper = createWrapper()
await flushPromises()
const currentPlanButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Current Plan'))
await currentPlanButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
})
it('should initiate checkout instead of billing portal for new subscribers', async () => {
mockIsActiveSubscription.value = false
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const wrapper = createWrapper()
await flushPromises()
const subscribeButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Subscribe'))
await subscribeButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).not.toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-checkout/'),
expect.any(Object)
)
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://checkout.stripe.com/test',
'_blank'
)
windowOpenSpy.mockRestore()
})
it('should pass correct tier for each subscription level', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'PRO'
const wrapper = createWrapper()
await flushPromises()
const standardButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Standard'))
await standardButton?.trigger('click')
await flushPromises()
expect(mockAccessBillingPortal).toHaveBeenCalledWith('standard-yearly')
})
})
})

View File

@@ -30,7 +30,9 @@ const mockAddCreditsResponse = {
const mockAccessBillingPortalResponse = {
ok: true,
statusText: 'OK'
statusText: 'OK',
json: () =>
Promise.resolve({ billing_portal_url: 'https://billing.stripe.com/test' })
}
vi.mock('vuefire', () => ({
@@ -129,7 +131,7 @@ describe('useFirebaseAuthStore', () => {
if (url.endsWith('/customers/credit')) {
return Promise.resolve(mockAddCreditsResponse)
}
if (url.endsWith('/customers/billing-portal')) {
if (url.endsWith('/customers/billing')) {
return Promise.resolve(mockAccessBillingPortalResponse)
}
return Promise.reject(new Error('Unexpected API call'))
@@ -542,4 +544,75 @@ describe('useFirebaseAuthStore', () => {
expect(store.loading).toBe(false)
})
})
describe('accessBillingPortal', () => {
it('should call billing endpoint without body when no targetTier provided', async () => {
const result = await store.accessBillingPortal()
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/billing'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer mock-id-token',
'Content-Type': 'application/json'
})
})
)
const callArgs = mockFetch.mock.calls.find((call) =>
(call[0] as string).endsWith('/customers/billing')
)
expect(callArgs?.[1]).not.toHaveProperty('body')
expect(result).toEqual({
billing_portal_url: 'https://billing.stripe.com/test'
})
})
it('should include target_tier in request body when targetTier provided', async () => {
await store.accessBillingPortal('creator')
const callArgs = mockFetch.mock.calls.find((call) =>
(call[0] as string).endsWith('/customers/billing')
)
expect(callArgs?.[1]).toHaveProperty('body')
expect(JSON.parse(callArgs?.[1]?.body as string)).toEqual({
target_tier: 'creator'
})
})
it('should handle different checkout tier formats', async () => {
const tiers = [
'standard',
'creator',
'pro',
'standard-yearly',
'creator-yearly',
'pro-yearly'
] as const
for (const tier of tiers) {
mockFetch.mockClear()
await store.accessBillingPortal(tier)
const callArgs = mockFetch.mock.calls.find((call) =>
(call[0] as string).endsWith('/customers/billing')
)
expect(JSON.parse(callArgs?.[1]?.body as string)).toEqual({
target_tier: tier
})
}
})
it('should throw error when API returns error response', async () => {
mockFetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
json: () => Promise.resolve({ message: 'Billing portal unavailable' })
})
)
await expect(store.accessBillingPortal()).rejects.toThrow()
})
})
})