mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-12 17:28:38 +00:00
Compare commits
3 Commits
main
...
alexis/upd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
516a2b02bb | ||
|
|
eb4d111a73 | ||
|
|
8c66843f6e |
@@ -137,8 +137,7 @@ export const TestIds = {
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button',
|
||||
bypass: 'bypass-button'
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
|
||||
@@ -129,18 +129,23 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// A group + a KSampler node
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group')
|
||||
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
|
||||
|
||||
// Select group + node should show bypass button
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(bypass).toBeVisible()
|
||||
await comfyPage.keyboard.delete()
|
||||
await comfyPage.page.keyboard.press('Control+A')
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
// (Only empty group is selected) should hide bypass button
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(bypass).toBeHidden()
|
||||
// Deselect node (Only group is selected) should hide bypass button
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Color Picker', () => {
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
@@ -219,40 +217,4 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('.')
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
|
||||
const toggleBypass = () =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
|
||||
const bypassCount = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.nodes.filter((node) => node.mode === 4).length
|
||||
)
|
||||
expect(await bypassCount()).toBe(0)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.select()
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await ksampler.select()
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,7 +101,6 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
|
||||
const {
|
||||
hasAnySelection,
|
||||
hasGroupedNodesSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
isSingleSubgraph,
|
||||
@@ -119,10 +118,7 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
const showBypass = computed(
|
||||
() =>
|
||||
isSingleNode.value ||
|
||||
isSingleSubgraph.value ||
|
||||
hasMultipleSelection.value ||
|
||||
hasGroupedNodesSelection.value
|
||||
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
|
||||
)
|
||||
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
|
||||
const showMaskEditor = computed(() => isSingleImageNode.value)
|
||||
|
||||
@@ -2,9 +2,6 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
CreateTopupResponse,
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
@@ -19,9 +16,7 @@ export interface SubscriptionInfo {
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
/** ISO 8601 */
|
||||
renewalDate: string | null
|
||||
/** ISO 8601 */
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
@@ -49,9 +44,6 @@ export interface BillingActions {
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
resubscribe: () => Promise<void>
|
||||
/** `amountCents` must be a whole-dollar multiple of 100. */
|
||||
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
* Ensures billing is initialized and subscription is active.
|
||||
@@ -73,12 +65,16 @@ export interface BillingState {
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
/**
|
||||
* Convenience computed for checking if subscription is active.
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
/**
|
||||
* Whether the current billing context has a FREE tier subscription.
|
||||
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
|
||||
*/
|
||||
isFreeTier: ComputedRef<boolean>
|
||||
billingStatus: ComputedRef<BillingStatus | null>
|
||||
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
|
||||
tier: ComputedRef<SubscriptionTier | null>
|
||||
renewalDate: ComputedRef<string | null>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
|
||||
@@ -5,17 +5,13 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn()
|
||||
}))
|
||||
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
|
||||
() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
@@ -54,9 +50,8 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
subscriptionTier: { value: 'PRO' },
|
||||
subscriptionDuration: { value: 'MONTHLY' },
|
||||
subscriptionStatus: {
|
||||
value: { renewal_date: '2025-01-01T00:00:00Z', end_date: null }
|
||||
},
|
||||
formattedRenewalDate: { value: 'Jan 1, 2025' },
|
||||
formattedEndDate: { value: '' },
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
manageSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -75,12 +70,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
purchaseCredits: mockPurchaseCredits
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
balance: { amount_micros: 5000000 },
|
||||
@@ -140,7 +129,7 @@ describe('useBillingContext', () => {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: '2025-01-01T00:00:00Z',
|
||||
renewalDate: 'Jan 1, 2025',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
@@ -184,13 +173,6 @@ describe('useBillingContext', () => {
|
||||
await expect(manageSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('converts topup cents to whole dollars for the legacy credit endpoint', async () => {
|
||||
const { topup } = useBillingContext()
|
||||
await topup(500)
|
||||
|
||||
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
|
||||
@@ -122,15 +122,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
|
||||
@@ -227,14 +218,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.cancelSubscription()
|
||||
}
|
||||
|
||||
async function resubscribe() {
|
||||
return activeContext.value.resubscribe()
|
||||
}
|
||||
|
||||
async function topup(amountCents: number) {
|
||||
return activeContext.value.topup(amountCents)
|
||||
}
|
||||
|
||||
async function fetchPlans() {
|
||||
return activeContext.value.fetchPlans()
|
||||
}
|
||||
@@ -258,10 +241,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
@@ -271,8 +250,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
@@ -27,7 +24,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
isActiveSubscription: legacyIsActiveSubscription,
|
||||
subscriptionTier,
|
||||
subscriptionDuration,
|
||||
subscriptionStatus: legacySubscriptionStatus,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
isCancelled,
|
||||
fetchStatus: legacyFetchStatus,
|
||||
manageSubscription: legacyManageSubscription,
|
||||
@@ -36,7 +34,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
} = useSubscription()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
@@ -55,8 +52,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
tier: subscriptionTier.value,
|
||||
duration: subscriptionDuration.value,
|
||||
planSlug: null, // Legacy doesn't use plan slugs
|
||||
renewalDate: legacySubscriptionStatus.value?.renewal_date ?? null,
|
||||
endDate: legacySubscriptionStatus.value?.end_date ?? null,
|
||||
renewalDate: formattedRenewalDate.value || null,
|
||||
endDate: formattedEndDate.value || null,
|
||||
isCancelled: isCancelled.value,
|
||||
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
|
||||
}
|
||||
@@ -78,18 +75,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
}
|
||||
})
|
||||
|
||||
// Legacy has no coarse billing_status concept (workspace-only).
|
||||
const billingStatus = computed<BillingStatus | null>(() => null)
|
||||
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
|
||||
if (isCancelled.value) return 'canceled'
|
||||
if (legacyIsActiveSubscription.value) return 'active'
|
||||
return null
|
||||
})
|
||||
const tier = computed(() => subscriptionTier.value)
|
||||
const renewalDate = computed(
|
||||
() => legacySubscriptionStatus.value?.renewal_date ?? null
|
||||
)
|
||||
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
@@ -167,16 +152,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function resubscribe(): Promise<void> {
|
||||
// Legacy has no resubscribe endpoint; resubscribing is a fresh checkout.
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function topup(amountCents: number): Promise<void> {
|
||||
// Facade standardizes on cents; legacy /customers/credit takes dollars.
|
||||
await authActions.purchaseCredits(amountCents / 100)
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
// Plans are hardcoded in the UI for legacy subscriptions
|
||||
@@ -204,10 +179,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
@@ -217,8 +188,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { uniq } from 'es-toolkit'
|
||||
|
||||
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { collectFromNodes } from '@/utils/graphTraversalUtil'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling selected LiteGraph items filtering and operations.
|
||||
@@ -73,13 +71,7 @@ export function useSelectedLiteGraphItems() {
|
||||
* the prior null-tolerance for callers wired to early-firing commands.
|
||||
*/
|
||||
const getSelectedNodesShallow = (): LGraphNode[] =>
|
||||
uniq(
|
||||
[...(canvasStore.canvas?.selectedItems ?? [])].flatMap((item) => {
|
||||
if (isLGraphNode(item)) return [item]
|
||||
if (isLGraphGroup(item)) return [...item.children].filter(isLGraphNode)
|
||||
return []
|
||||
})
|
||||
)
|
||||
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
|
||||
|
||||
/**
|
||||
* Get only the selected nodes (LGraphNode instances) from the canvas.
|
||||
|
||||
@@ -7,12 +7,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import {
|
||||
isImageNode,
|
||||
isLGraphGroup,
|
||||
isLGraphNode,
|
||||
isLoad3dNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
export interface NodeSelectionState {
|
||||
@@ -46,11 +41,6 @@ export function useSelectionState() {
|
||||
const hasAnySelection = computed(() => selectedItems.value.length > 0)
|
||||
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
|
||||
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
|
||||
const hasGroupedNodesSelection = computed(() =>
|
||||
selectedItems.value.some(
|
||||
(item) => isLGraphGroup(item) && [...item.children].some(isLGraphNode)
|
||||
)
|
||||
)
|
||||
|
||||
const isSingleNode = computed(
|
||||
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
|
||||
@@ -122,7 +112,6 @@ export function useSelectionState() {
|
||||
openNodeInfo,
|
||||
hasAny3DNodeSelected,
|
||||
hasAnySelection,
|
||||
hasGroupedNodesSelection,
|
||||
hasSingleSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
|
||||
@@ -147,8 +147,7 @@ describe('OAuthConsentView', () => {
|
||||
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrfToken: 'csrf-token',
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace',
|
||||
expectedRedirectUri: 'http://127.0.0.1:50632/cb'
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -283,8 +283,7 @@ async function submit(decision: 'allow' | 'deny') {
|
||||
oauthRequestId: challenge.value.oauth_request_id,
|
||||
csrfToken: challenge.value.csrf_token,
|
||||
decision,
|
||||
workspaceId,
|
||||
expectedRedirectUri: challenge.value.redirect_uri
|
||||
workspaceId
|
||||
})
|
||||
clearOAuthRequestId()
|
||||
} catch (error) {
|
||||
|
||||
@@ -220,111 +220,6 @@ describe('submitOAuthConsentDecision', () => {
|
||||
).rejects.toThrow('redirect_url')
|
||||
})
|
||||
|
||||
it('navigates to a reverse-DNS custom-scheme redirect_url (native clients)', async () => {
|
||||
// RFC 8252 native-app callback — the comfy-ios client returns the
|
||||
// authorization code via org.comfy.ios://oauth-callback. The backend
|
||||
// has already validated the URL byte-identically against the client's
|
||||
// registered redirect_uris.
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({
|
||||
redirect_url: 'org.comfy.ios://oauth-callback?code=xyz&state=s'
|
||||
})
|
||||
)
|
||||
const originalLocation = globalThis.location
|
||||
const hrefSetter = vi.fn()
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new Proxy(originalLocation, {
|
||||
set(_target, prop, value) {
|
||||
if (prop === 'href') {
|
||||
hrefSetter(value)
|
||||
return true
|
||||
}
|
||||
return Reflect.set(originalLocation, prop, value)
|
||||
},
|
||||
get(_target, prop) {
|
||||
return Reflect.get(originalLocation, prop)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace',
|
||||
expectedRedirectUri: 'org.comfy.ios://oauth-callback'
|
||||
})
|
||||
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
'org.comfy.ios://oauth-callback?code=xyz&state=s'
|
||||
)
|
||||
expect(hrefSetter).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it.for([
|
||||
[
|
||||
'org.comfy.ios://oauth-callback?code=xyz',
|
||||
undefined,
|
||||
'unsafe scheme',
|
||||
'custom scheme with no expectedRedirectUri is unbindable, falls back to the http(s)-only rule'
|
||||
],
|
||||
[
|
||||
'com.evil.app://oauth-callback?code=xyz',
|
||||
'org.comfy.ios://oauth-callback',
|
||||
'does not match',
|
||||
'bound challenge, different scheme: wrong-client redirect'
|
||||
],
|
||||
[
|
||||
'org.comfy.ios://oauth-callback/../steal?code=xyz',
|
||||
'org.comfy.ios://oauth-callback',
|
||||
'does not match',
|
||||
'bound challenge, same scheme but different path'
|
||||
],
|
||||
[
|
||||
'javascript:alert(1)',
|
||||
'javascript:alert(1)',
|
||||
'unsafe scheme',
|
||||
'executable schemes are rejected even if the challenge claims them'
|
||||
],
|
||||
[
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'data:text/html,x',
|
||||
'unsafe scheme',
|
||||
'data: scheme rejected even if the challenge claims it'
|
||||
],
|
||||
[
|
||||
'blob:https://cloud.comfy.org/abc',
|
||||
undefined,
|
||||
'unsafe scheme',
|
||||
'blob: scheme is unsafe'
|
||||
]
|
||||
] as const)(
|
||||
'rejects redirect_url %s (registration %s, expects %s): %s',
|
||||
async ([redirectUrl, expectedRedirectUri, expectedError]) => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({ redirect_url: redirectUrl })
|
||||
)
|
||||
|
||||
await expect(
|
||||
submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace',
|
||||
expectedRedirectUri
|
||||
})
|
||||
).rejects.toThrow(expectedError)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects an unsafe redirect_url scheme', async () => {
|
||||
// Defense in depth: even though the cloud backend is trusted, never
|
||||
// hand the browser off to a non-http(s) URL.
|
||||
|
||||
@@ -40,33 +40,12 @@ export type OAuthConsentDecisionParams = {
|
||||
csrfToken: string
|
||||
decision: 'allow' | 'deny'
|
||||
workspaceId: string
|
||||
/**
|
||||
* The challenge's registered `redirect_uri`. When present, the
|
||||
* post-consent navigation must match it (scheme, authority, path) —
|
||||
* the server only appends `code`/`state` query params to the
|
||||
* registered URI, so any other destination is rejected. When absent
|
||||
* (challenges from backends that don't surface it yet), only http(s)
|
||||
* redirects are navigable.
|
||||
*/
|
||||
expectedRedirectUri?: string
|
||||
}
|
||||
|
||||
export type OAuthConsentDecision = (
|
||||
params: OAuthConsentDecisionParams
|
||||
) => Promise<void>
|
||||
|
||||
// Schemes that execute in our origin if navigated. Never navigable,
|
||||
// regardless of what the backend returns. Everything else is governed
|
||||
// by binding to the challenge's registered redirect_uri — no per-client
|
||||
// scheme knowledge lives in the frontend.
|
||||
const EXECUTABLE_SCHEMES: ReadonlySet<string> = new Set([
|
||||
'javascript:',
|
||||
'data:',
|
||||
'blob:',
|
||||
'vbscript:',
|
||||
'about:'
|
||||
])
|
||||
|
||||
export class OAuthApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -139,8 +118,7 @@ export async function submitOAuthConsentDecision({
|
||||
oauthRequestId,
|
||||
csrfToken,
|
||||
decision,
|
||||
workspaceId,
|
||||
expectedRedirectUri
|
||||
workspaceId
|
||||
}: OAuthConsentDecisionParams): Promise<void> {
|
||||
const response = await fetch('/oauth/authorize', {
|
||||
method: 'POST',
|
||||
@@ -166,56 +144,13 @@ export async function submitOAuthConsentDecision({
|
||||
throw new Error('OAuth consent response did not include redirect_url')
|
||||
}
|
||||
|
||||
// Defense in depth at this sink. Two risks: schemes that execute in our
|
||||
// origin (always rejected, below), and the OS routing the authorization
|
||||
// code + state to whichever installed app claims an arbitrary custom
|
||||
// scheme. For the latter we hold the navigation to the redirect the
|
||||
// backend registered for THIS auth request (the challenge's
|
||||
// redirect_uri): the server only ever appends code/state query params
|
||||
// to the registered URI, so scheme, authority, and path must match
|
||||
// exactly. No per-client scheme list lives in the frontend — new native
|
||||
// clients need only their backend registration.
|
||||
const parseTarget = () => {
|
||||
try {
|
||||
return new URL(redirectUrl, globalThis.location.origin)
|
||||
} catch (err) {
|
||||
throw new Error('OAuth consent redirect_url is not a valid URL', {
|
||||
cause: err
|
||||
})
|
||||
}
|
||||
}
|
||||
const target = parseTarget()
|
||||
if (EXECUTABLE_SCHEMES.has(target.protocol)) {
|
||||
throw new Error('OAuth consent redirect_url has an unsafe scheme')
|
||||
}
|
||||
if (expectedRedirectUri) {
|
||||
const parseExpected = () => {
|
||||
try {
|
||||
return new URL(expectedRedirectUri)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'OAuth consent challenge redirect_uri is not a valid URL',
|
||||
{ cause: err }
|
||||
)
|
||||
}
|
||||
}
|
||||
const expected = parseExpected()
|
||||
const matchesRegistration =
|
||||
target.protocol === expected.protocol &&
|
||||
target.host === expected.host &&
|
||||
target.pathname === expected.pathname
|
||||
if (!matchesRegistration) {
|
||||
throw new Error(
|
||||
'OAuth consent redirect_url does not match the registered redirect_uri'
|
||||
)
|
||||
}
|
||||
} else if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||
// Challenges that don't surface redirect_uri can't be bound; hold the
|
||||
// pre-existing http(s)-only line for them.
|
||||
// Defense in depth: even though the cloud backend is trusted, never hand
|
||||
// the browser off to a non-http(s) scheme. javascript:/data: URLs would
|
||||
// execute in our origin.
|
||||
const target = new URL(redirectUrl, globalThis.location.origin)
|
||||
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||
throw new Error('OAuth consent redirect_url has an unsafe scheme')
|
||||
}
|
||||
|
||||
// Navigate the parsed URL, not the raw string, so the value validated
|
||||
// above is byte-for-byte the value the browser receives.
|
||||
globalThis.location.href = target.href
|
||||
globalThis.location.href = redirectUrl
|
||||
}
|
||||
|
||||
@@ -196,13 +196,9 @@ export interface PreviewSubscribeResponse {
|
||||
new_plan: PreviewPlanInfo
|
||||
}
|
||||
|
||||
export type BillingSubscriptionStatus =
|
||||
| 'active'
|
||||
| 'scheduled'
|
||||
| 'ended'
|
||||
| 'canceled'
|
||||
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
|
||||
|
||||
export type BillingStatus =
|
||||
type BillingStatus =
|
||||
| 'awaiting_payment_method'
|
||||
| 'pending_payment'
|
||||
| 'paid'
|
||||
@@ -237,7 +233,7 @@ interface CreateTopupRequest {
|
||||
|
||||
type TopupStatus = 'pending' | 'completed' | 'failed'
|
||||
|
||||
export interface CreateTopupResponse {
|
||||
interface CreateTopupResponse {
|
||||
billing_op_id: string
|
||||
topup_id: string
|
||||
status: TopupStatus
|
||||
|
||||
@@ -371,6 +371,7 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
@@ -403,8 +404,7 @@ const {
|
||||
manageSubscription,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
getMaxSeats,
|
||||
resubscribe
|
||||
getMaxSeats
|
||||
} = useBillingContext()
|
||||
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
@@ -415,12 +415,13 @@ const isResubscribing = ref(false)
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await resubscribe()
|
||||
await workspaceApi.resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||
|
||||
@@ -161,6 +161,7 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -176,7 +177,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { fetchBalance, topup } = useBillingContext()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
@@ -256,8 +257,7 @@ async function handleBuy() {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
|
||||
const amountCents = payAmount.value * 100
|
||||
const response = await topup(amountCents)
|
||||
if (!response) return
|
||||
const response = await workspaceApi.createTopup(amountCents)
|
||||
|
||||
if (response.status === 'completed') {
|
||||
toast.add({
|
||||
|
||||
@@ -91,12 +91,10 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
previewSubscribe: mockPreviewSubscribe,
|
||||
plans: computed(() => mockPlans.value),
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance,
|
||||
resubscribe: mockResubscribe
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
// Shields the test from the real workspaceApi → @/scripts/api → app.ts import chain
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { resubscribe: mockResubscribe }
|
||||
}))
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview'
|
||||
@@ -34,14 +35,8 @@ export function useSubscriptionCheckout(emit: {
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const {
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
plans,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
resubscribe
|
||||
} = useBillingContext()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
|
||||
@@ -175,12 +170,13 @@ export function useSubscriptionCheckout(emit: {
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await resubscribe()
|
||||
await workspaceApi.resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
||||
@@ -11,9 +11,7 @@ const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
subscribe: vi.fn(),
|
||||
previewSubscribe: vi.fn(),
|
||||
getPaymentPortalUrl: vi.fn(),
|
||||
cancelSubscription: vi.fn(),
|
||||
resubscribe: vi.fn(),
|
||||
createTopup: vi.fn()
|
||||
cancelSubscription: vi.fn()
|
||||
}))
|
||||
|
||||
const mockBillingPlans = vi.hoisted(() => ({
|
||||
@@ -624,90 +622,6 @@ describe('useWorkspaceBilling', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resubscribe', () => {
|
||||
it('refreshes status and balance after a successful resubscribe', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockResolvedValue(undefined)
|
||||
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
|
||||
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
|
||||
|
||||
const billing = setupBilling()
|
||||
await billing.resubscribe()
|
||||
|
||||
expect(mockWorkspaceApi.resubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockWorkspaceApi.getBillingBalance).toHaveBeenCalledTimes(1)
|
||||
expect(billing.subscription.value?.tier).toBe('CREATOR')
|
||||
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
|
||||
expect(billing.error.value).toBeNull()
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets error, rethrows, and skips the refresh when the API call fails', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockRejectedValue(
|
||||
new Error('reactivation failed')
|
||||
)
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.resubscribe()).rejects.toThrow('reactivation failed')
|
||||
expect(billing.error.value).toBe('reactivation failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for non-Error rejections', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockRejectedValue('boom')
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.resubscribe()).rejects.toBe('boom')
|
||||
expect(billing.error.value).toBe('Failed to resubscribe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('topup', () => {
|
||||
const topupResponse = {
|
||||
billing_op_id: 'op-topup',
|
||||
topup_id: 'topup-1',
|
||||
status: 'completed' as const,
|
||||
amount_cents: 500
|
||||
}
|
||||
|
||||
it('returns the createTopup response without refreshing status or balance', async () => {
|
||||
mockWorkspaceApi.createTopup.mockResolvedValue(topupResponse)
|
||||
|
||||
const billing = setupBilling()
|
||||
const result = await billing.topup(500)
|
||||
|
||||
expect(mockWorkspaceApi.createTopup).toHaveBeenCalledWith(500)
|
||||
expect(result).toBe(topupResponse)
|
||||
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
|
||||
expect(billing.error.value).toBeNull()
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets error and rethrows when the API call fails', async () => {
|
||||
mockWorkspaceApi.createTopup.mockRejectedValue(new Error('card declined'))
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.topup(500)).rejects.toThrow('card declined')
|
||||
expect(billing.error.value).toBe('card declined')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for non-Error rejections', async () => {
|
||||
mockWorkspaceApi.createTopup.mockRejectedValue('boom')
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.topup(500)).rejects.toBe('boom')
|
||||
expect(billing.error.value).toBe('Failed to top up credits')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plans / currentPlanSlug / fetchPlans', () => {
|
||||
it('prefers the plan slug from status over the billingPlans fallback', async () => {
|
||||
mockBillingPlans.currentPlanSlug.value = 'plans-fallback'
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
CreateTopupResponse,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
@@ -71,13 +70,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
}
|
||||
})
|
||||
|
||||
const billingStatus = computed(() => statusData.value?.billing_status ?? null)
|
||||
const subscriptionStatus = computed(
|
||||
() => statusData.value?.subscription_status ?? null
|
||||
)
|
||||
const tier = computed(() => statusData.value?.subscription_tier ?? null)
|
||||
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
|
||||
|
||||
const plans = computed(() => billingPlans.plans.value)
|
||||
const currentPlanSlug = computed(
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
@@ -270,34 +262,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
}
|
||||
}
|
||||
|
||||
async function resubscribe(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to resubscribe'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function topup(amountCents: number): Promise<CreateTopupResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await workspaceApi.createTopup(amountCents)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to top up credits'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
@@ -339,10 +303,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
@@ -352,8 +312,6 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||
@@ -1,149 +1,407 @@
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
export const defaultGraph: ComfyWorkflowJSON = {
|
||||
last_node_id: 9,
|
||||
last_link_id: 9,
|
||||
last_node_id: 71,
|
||||
last_link_id: 82,
|
||||
nodes: [
|
||||
{
|
||||
id: 7,
|
||||
type: 'CLIPTextEncode',
|
||||
pos: [413, 389],
|
||||
size: [425.27801513671875, 180.6060791015625],
|
||||
id: 9,
|
||||
type: 'SaveImage',
|
||||
pos: [1279.9999726783708, 319.9999392082668],
|
||||
size: [300, 420],
|
||||
flags: {},
|
||||
order: 3,
|
||||
order: 9,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'clip', type: 'CLIP', link: 5 }],
|
||||
outputs: [
|
||||
inputs: [
|
||||
{
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
links: [6],
|
||||
slot_index: 0
|
||||
name: 'images',
|
||||
type: 'IMAGE',
|
||||
link: 80
|
||||
}
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: ['text, watermark']
|
||||
outputs: [],
|
||||
properties: {
|
||||
'Node name for S&R': 'SaveImage',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.64',
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: ['ComfyUI']
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'CLIPTextEncode',
|
||||
pos: [415, 186],
|
||||
size: [422.84503173828125, 164.31304931640625],
|
||||
id: 62,
|
||||
type: 'CLIPLoader',
|
||||
pos: [-239.9999987113997, 420.0000536491848],
|
||||
size: [340, 169.3125],
|
||||
flags: {},
|
||||
order: 2,
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'clip', type: 'CLIP', link: 3 }],
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
links: [4],
|
||||
slot_index: 0
|
||||
name: 'CLIP',
|
||||
type: 'CLIP',
|
||||
links: [79, 81]
|
||||
}
|
||||
],
|
||||
properties: {},
|
||||
widgets_values: [
|
||||
'beautiful scenery nature glass bottle landscape, , purple galaxy bottle,'
|
||||
]
|
||||
properties: {
|
||||
'Node name for S&R': 'CLIPLoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.73',
|
||||
models: [
|
||||
{
|
||||
name: 'qwen_3_4b.safetensors',
|
||||
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors',
|
||||
directory: 'text_encoders'
|
||||
}
|
||||
],
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: ['qwen_3_4b.safetensors', 'lumina2', 'default']
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'EmptyLatentImage',
|
||||
pos: [473, 609],
|
||||
size: [315, 106],
|
||||
id: 63,
|
||||
type: 'VAELoader',
|
||||
pos: [659.9998200904802, 699.9998629143215],
|
||||
size: [320, 106.65625],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
outputs: [{ name: 'LATENT', type: 'LATENT', links: [2], slot_index: 0 }],
|
||||
properties: {},
|
||||
widgets_values: [512, 512, 1]
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'VAE',
|
||||
type: 'VAE',
|
||||
links: [74]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'VAELoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.73',
|
||||
models: [
|
||||
{
|
||||
name: 'ae.safetensors',
|
||||
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors',
|
||||
directory: 'vae'
|
||||
}
|
||||
],
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: ['ae.safetensors']
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'KSampler',
|
||||
pos: [863, 186],
|
||||
size: [315, 262],
|
||||
id: 65,
|
||||
type: 'VAEDecode',
|
||||
pos: [1019.9998200904802, 319.9999392082668],
|
||||
size: [225, 96],
|
||||
flags: {},
|
||||
order: 8,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'samples',
|
||||
type: 'LATENT',
|
||||
link: 73
|
||||
},
|
||||
{
|
||||
name: 'vae',
|
||||
type: 'VAE',
|
||||
link: 74
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
slot_index: 0,
|
||||
links: [80]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'VAEDecode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.64',
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: []
|
||||
},
|
||||
{
|
||||
id: 66,
|
||||
type: 'UNETLoader',
|
||||
pos: [-239.9999987113997, 110.00000596546897],
|
||||
size: [380, 134.65625],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'MODEL',
|
||||
type: 'MODEL',
|
||||
links: [72]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'UNETLoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.73',
|
||||
models: [
|
||||
{
|
||||
name: 'z_image_turbo_bf16.safetensors',
|
||||
url: 'https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors',
|
||||
directory: 'diffusion_models'
|
||||
}
|
||||
],
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: ['z_image_turbo_bf16.safetensors', 'default']
|
||||
},
|
||||
{
|
||||
id: 67,
|
||||
type: 'CLIPTextEncode',
|
||||
pos: [170.00001082534345, 290.0000536491848],
|
||||
size: [410, 160],
|
||||
flags: {},
|
||||
order: 4,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: 'model', type: 'MODEL', link: 1 },
|
||||
{ name: 'positive', type: 'CONDITIONING', link: 4 },
|
||||
{ name: 'negative', type: 'CONDITIONING', link: 6 },
|
||||
{ name: 'latent_image', type: 'LATENT', link: 2 }
|
||||
{
|
||||
name: 'clip',
|
||||
type: 'CLIP',
|
||||
link: 79
|
||||
}
|
||||
],
|
||||
outputs: [{ name: 'LATENT', type: 'LATENT', links: [7], slot_index: 0 }],
|
||||
properties: {},
|
||||
widgets_values: [156680208700286, true, 20, 8, 'euler', 'normal', 1]
|
||||
outputs: [
|
||||
{
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
links: [76]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'CLIPTextEncode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.73',
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: [
|
||||
'anime RPG game style, cute anime girl with gigantic fennec ears and a big fluffy fox tail with long wavy blonde hair and large blue eyes blonde colored eyelashes wearing a pink sweater a large oversized gold trimmed black winter coat and a long blue maxi skirt and a red scarf, she is sitting beside a campfire in the wilderness at night playing guitar with a milky way galaxy sky'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'VAEDecode',
|
||||
pos: [1209, 188],
|
||||
size: [210, 46],
|
||||
id: 68,
|
||||
type: 'EmptySD3LatentImage',
|
||||
pos: [310.0000489723161, 700.0000155022121],
|
||||
size: [260, 168],
|
||||
flags: {},
|
||||
order: 3,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'LATENT',
|
||||
type: 'LATENT',
|
||||
slot_index: 0,
|
||||
links: [78]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'EmptySD3LatentImage',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.64',
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: [1024, 1024, 1]
|
||||
},
|
||||
{
|
||||
id: 69,
|
||||
type: 'ModelSamplingAuraFlow',
|
||||
pos: [220.00001082534345, 110.00000596546897],
|
||||
size: [310, 104],
|
||||
flags: {},
|
||||
order: 6,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'MODEL',
|
||||
link: 72
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
name: 'MODEL',
|
||||
type: 'MODEL',
|
||||
slot_index: 0,
|
||||
links: [75]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'ModelSamplingAuraFlow',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.64',
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: [3]
|
||||
},
|
||||
{
|
||||
id: 70,
|
||||
type: 'KSampler',
|
||||
pos: [659.9998200904802, 319.9999392082668],
|
||||
size: [315, 341.3125],
|
||||
flags: {},
|
||||
order: 7,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'MODEL',
|
||||
link: 75
|
||||
},
|
||||
{
|
||||
name: 'positive',
|
||||
type: 'CONDITIONING',
|
||||
link: 76
|
||||
},
|
||||
{
|
||||
name: 'negative',
|
||||
type: 'CONDITIONING',
|
||||
link: 82
|
||||
},
|
||||
{
|
||||
name: 'latent_image',
|
||||
type: 'LATENT',
|
||||
link: 78
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
name: 'LATENT',
|
||||
type: 'LATENT',
|
||||
slot_index: 0,
|
||||
links: [73]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'KSampler',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.64',
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: [42, 'fixed', 8, 1, 'res_multistep', 'simple', 1]
|
||||
},
|
||||
{
|
||||
id: 71,
|
||||
type: 'CLIPTextEncode',
|
||||
pos: [170.00001082534345, 520.0000536491848],
|
||||
size: [405.46875, 140],
|
||||
flags: {},
|
||||
order: 5,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: 'samples', type: 'LATENT', link: 7 },
|
||||
{ name: 'vae', type: 'VAE', link: 8 }
|
||||
{
|
||||
name: 'clip',
|
||||
type: 'CLIP',
|
||||
link: 81
|
||||
}
|
||||
],
|
||||
outputs: [{ name: 'IMAGE', type: 'IMAGE', links: [9], slot_index: 0 }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
type: 'SaveImage',
|
||||
pos: [1451, 189],
|
||||
size: [210, 26],
|
||||
flags: {},
|
||||
order: 6,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'images', type: 'IMAGE', link: 9 }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [26, 474],
|
||||
size: [315, 98],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
outputs: [
|
||||
{ name: 'MODEL', type: 'MODEL', links: [1], slot_index: 0 },
|
||||
{ name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 },
|
||||
{ name: 'VAE', type: 'VAE', links: [8], slot_index: 2 }
|
||||
{
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
links: [82]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'v1-5-pruned-emaonly-fp16.safetensors',
|
||||
url: 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
'Node name for S&R': 'CLIPTextEncode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.73',
|
||||
enableTabs: false,
|
||||
tabWidth: 65,
|
||||
tabXOffset: 10,
|
||||
hasSecondTab: false,
|
||||
secondTabText: 'Send Back',
|
||||
secondTabOffset: 80,
|
||||
secondTabWidth: 65
|
||||
},
|
||||
widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors']
|
||||
widgets_values: [
|
||||
'low quality, bad anatomy, extra digits, missing digits, extra limbs, missing limbs'
|
||||
]
|
||||
}
|
||||
],
|
||||
links: [
|
||||
[1, 4, 0, 3, 0, 'MODEL'],
|
||||
[2, 5, 0, 3, 3, 'LATENT'],
|
||||
[3, 4, 1, 6, 0, 'CLIP'],
|
||||
[4, 6, 0, 3, 1, 'CONDITIONING'],
|
||||
[5, 4, 1, 7, 0, 'CLIP'],
|
||||
[6, 7, 0, 3, 2, 'CONDITIONING'],
|
||||
[7, 3, 0, 8, 0, 'LATENT'],
|
||||
[8, 4, 2, 8, 1, 'VAE'],
|
||||
[9, 8, 0, 9, 0, 'IMAGE']
|
||||
[72, 66, 0, 69, 0, 'MODEL'],
|
||||
[73, 70, 0, 65, 0, 'LATENT'],
|
||||
[74, 63, 0, 65, 1, 'VAE'],
|
||||
[75, 69, 0, 70, 0, 'MODEL'],
|
||||
[76, 67, 0, 70, 1, 'CONDITIONING'],
|
||||
[78, 68, 0, 70, 3, 'LATENT'],
|
||||
[79, 62, 0, 67, 0, 'CLIP'],
|
||||
[80, 65, 0, 9, 0, 'IMAGE'],
|
||||
[81, 62, 0, 71, 0, 'CLIP'],
|
||||
[82, 71, 0, 70, 2, 'CONDITIONING']
|
||||
],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
scale: 0.9,
|
||||
offset: [416, 110]
|
||||
}
|
||||
},
|
||||
version: 0.4
|
||||
|
||||
Reference in New Issue
Block a user