Merge remote-tracking branch 'origin/main' into bl-teenage-planarian
@@ -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.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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 ({
|
||||
|
||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
8
public/assets/images/hf-logo.svg
Normal file
|
After Width: | Height: | Size: 34 KiB |
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
48
src/extensions/core/imageCompare.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import './electronAdapter'
|
||||
import './groupNode'
|
||||
import './groupNodeManage'
|
||||
import './groupOptions'
|
||||
import './imageCompare'
|
||||
import './load3d'
|
||||
import './maskeditor'
|
||||
import './nodeTemplates'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: 3–10s (max 10s)\n - video: 3–30s (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",
|
||||
|
||||
@@ -460,4 +460,4 @@
|
||||
"pysssss_SnapToGrid": {
|
||||
"name": "Always snap to grid"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "切换主题"
|
||||
},
|
||||
|
||||
@@ -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": "始终吸附到网格"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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[],
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
src/platform/assets/importSources/civitaiImportSource.ts
Normal 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']
|
||||
}
|
||||
10
src/platform/assets/importSources/huggingfaceImportSource.ts
Normal 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']
|
||||
}
|
||||
24
src/platform/assets/types/importSource.ts
Normal 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[]
|
||||
}
|
||||
15
src/platform/assets/utils/importSourceUtil.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,7 +43,12 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
{{
|
||||
slotData.label ||
|
||||
slotData.localized_name ||
|
||||
slotData.name ||
|
||||
`Input ${index}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||