mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 01:04:58 +00:00
Compare commits
2 Commits
codex/fix-
...
fix/backpo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
341e7cf34c | ||
|
|
11b2302a75 |
@@ -15,11 +15,6 @@ reviews:
|
||||
- github-actions[bot]
|
||||
pre_merge_checks:
|
||||
override_requested_reviewers_only: true
|
||||
# Explicitly disable the built-in docstring coverage check, which is
|
||||
# enabled via organization-level settings. This repo opts out at the
|
||||
# repo level without affecting other org repos.
|
||||
docstrings:
|
||||
mode: 'off'
|
||||
custom_checks:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
|
||||
10
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
10
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -85,16 +85,6 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Strip non-source entries from coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
|
||||
lcov --remove coverage/playwright/coverage.lcov \
|
||||
'*localhost-8188*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
5
.github/workflows/pr-backport.yaml
vendored
5
.github/workflows/pr-backport.yaml
vendored
@@ -67,6 +67,11 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Persist a token with `workflow` scope so the backport push can
|
||||
# include changes to .github/workflows/**. The default GITHUB_TOKEN
|
||||
# is refused by GitHub when a push creates/updates workflow files,
|
||||
# which silently aborted the whole job (see PR #12804 backport).
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
|
||||
@@ -78,11 +78,6 @@ const config: StorybookConfig = {
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/billing/useBillingContext',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
@@ -41,6 +42,7 @@ setup((app) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ function toggle(index: number) {
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@ function toggle(index: number) {
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
@@ -83,7 +83,7 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -40,12 +40,12 @@ const {
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
@@ -66,10 +66,10 @@ const {
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
|
||||
@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
<template>
|
||||
<footer
|
||||
ref="footerRef"
|
||||
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
|
||||
>
|
||||
<div
|
||||
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"
|
||||
|
||||
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
|
||||
<div class="flex w-full items-end justify-between p-4">
|
||||
<div class="gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
|
||||
<!-- Mobile metadata -->
|
||||
<div v-if="mobile" class="mt-2 gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
|
||||
>
|
||||
<h1
|
||||
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
|
||||
@@ -8,7 +8,7 @@ export class BaseDialog {
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
|
||||
@@ -352,11 +352,20 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page.getByTestId('assets-selection-bar')
|
||||
this.selectionCountButton = page.getByText(/\d+ selected/)
|
||||
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
|
||||
@@ -36,11 +36,9 @@ export class BuilderSaveAsHelper {
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
// The icon-only X carries an aria-label, while the footer Close button
|
||||
// is named by its text — getByLabel only matches the former.
|
||||
this.dismissButton = this.successDialog.getByLabel('Close', {
|
||||
exact: true
|
||||
})
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
|
||||
@@ -231,22 +231,6 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_interrupted` WS event (user-initiated stop). */
|
||||
executionInterrupted(jobId: string, nodeId: string): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_interrupted',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
timestamp: Date.now(),
|
||||
node_id: nodeId,
|
||||
node_type: 'Unknown',
|
||||
executed: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.requireWs().send(
|
||||
|
||||
@@ -38,6 +38,7 @@ export const TestIds = {
|
||||
settings: 'settings-dialog',
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
|
||||
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('Use button ghost-places a loader populated with the model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const card = comfyPage.page.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
|
||||
)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
// Dialog closes and the ghost is armed; the node is not placed until the
|
||||
// user clicks the canvas.
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
@@ -17,31 +14,15 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(APP_URL)
|
||||
await page.goto('http://localhost:8188')
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('preserves share auth attribution before redirecting logged-out users', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(new URL('/?share=abc', APP_URL).toString())
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => sessionStorage.getItem(key),
|
||||
SHARE_AUTH_STORAGE_KEY
|
||||
)
|
||||
)
|
||||
.toBe(JSON.stringify({ share: 'abc' }))
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto(APP_URL)
|
||||
await page.goto('http://localhost:8188')
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CustomerBalanceResponse = NonNullable<
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
|
||||
const FUTURE_DATE = '2099-01-01T00:00:00Z'
|
||||
|
||||
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
|
||||
|
||||
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenResponse: WorkspaceTokenResponse = {
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: FUTURE_DATE,
|
||||
workspace: {
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal'
|
||||
},
|
||||
role: 'owner',
|
||||
permissions: []
|
||||
}
|
||||
|
||||
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
|
||||
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
|
||||
// in the credits row.
|
||||
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_e2e',
|
||||
renewal_date: FUTURE_DATE,
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
amount_micros: 3_000_000,
|
||||
effective_balance_micros: 3_000_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRemoteConfig)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListWorkspacesResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/auth/token', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/cloud-subscription-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockSubscriptionStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
|
||||
test('keeps both action buttons inside the popover when cancelled but active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
const addCredits = page.getByTestId('add-credits-button')
|
||||
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
|
||||
await expect(addCredits).toBeVisible()
|
||||
await expect(resubscribe).toBeVisible()
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
const resubscribeBox = await resubscribe.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(resubscribeBox).not.toBeNull()
|
||||
|
||||
const popoverRight = popoverBox!.x + popoverBox!.width
|
||||
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
|
||||
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
|
||||
})
|
||||
})
|
||||
@@ -99,15 +99,15 @@ async function mockShareableAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
|
||||
* auth-triggered modals by pressing Escape until they clear.
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const dialogs = page.getByRole('dialog')
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await dialogs.count()) === 0) break
|
||||
if ((await mask.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await dialogs
|
||||
await mask
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
|
||||
@@ -612,23 +612,18 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
|
||||
// around each individual gesture. Holding the modifiers down across all
|
||||
// three drags plus the intervening screenshot assertions could saturate the
|
||||
// main thread and stall a single mouse.move step past the test timeout, and
|
||||
// a mid-test failure would leave the modifiers stuck down. Releasing per
|
||||
// gesture matches the robust pattern used in canvasSettings.spec.ts.
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
{ x: 10, y: 280 },
|
||||
{ x: 10, y: 220 }
|
||||
)
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-default-ctrl-shift.png'
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
})
|
||||
|
||||
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
|
||||
|
||||
@@ -254,8 +254,21 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
@@ -275,17 +288,20 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
expect(
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
@@ -34,17 +34,19 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
const request = route.request()
|
||||
if (!observedContentType) {
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
}
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
@@ -67,11 +69,24 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
@@ -80,6 +95,7 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
|
||||
@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -151,11 +151,21 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -13,6 +13,10 @@ import type {
|
||||
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
|
||||
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
@@ -176,10 +180,12 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
@@ -188,9 +194,11 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
@@ -227,8 +235,10 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -276,9 +286,11 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -286,13 +298,16 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -327,8 +342,10 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
@@ -338,6 +355,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
|
||||
@@ -373,8 +391,10 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -385,9 +405,11 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
@@ -398,8 +420,10 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -407,10 +431,15 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
@@ -419,11 +448,14 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
@@ -449,8 +481,10 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
@@ -531,6 +565,8 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// job-gamma is the first card; its detail carries a valid workflow so
|
||||
// extraction succeeds and the filename prompt opens.
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
@@ -578,6 +614,8 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Strip the workflow field so extraction yields null and the export
|
||||
// action returns { success: false, error: 'No workflow…' }.
|
||||
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
|
||||
|
||||
@@ -587,6 +625,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
// Filename prompt should be skipped: extraction fails before the prompt.
|
||||
await expect(comfyPage.toast.toastWarnings).toBeVisible()
|
||||
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
|
||||
})
|
||||
@@ -600,18 +639,23 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// useKeyModifier('Control') needs keyboard events, not click modifiers.
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
// dispatchEvent avoids the selection footer intercepting a right click.
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
@@ -620,6 +664,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -647,6 +692,7 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -658,6 +704,7 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -665,67 +712,21 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select the two single-output assets (job-alpha, job-beta).
|
||||
// The count reflects total outputs, not cards — job-gamma has
|
||||
// outputs_count: 2 which would inflate the total.
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection count sums the outputs of a stacked asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection bar stays capped, not stretched, on a wide panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1600, height: 900 })
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
const gutter = comfyPage.page.locator('.p-splitter-gutter').first()
|
||||
await expect(gutter).toBeVisible()
|
||||
const gutterBox = await gutter.boundingBox()
|
||||
if (!gutterBox) {
|
||||
throw new Error('sidebar splitter gutter has no bounding box')
|
||||
}
|
||||
await comfyPage.page.mouse.move(
|
||||
gutterBox.x + gutterBox.width / 2,
|
||||
gutterBox.y + gutterBox.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(900, gutterBox.y + gutterBox.height / 2, {
|
||||
steps: 12
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
|
||||
await expect
|
||||
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(520)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tab.selectionFooter.boundingBox()
|
||||
const side = await sidebar.boundingBox()
|
||||
return bar && side ? side.width - bar.width : 0
|
||||
})
|
||||
.toBeGreaterThan(100)
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -832,7 +833,8 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Queue polling also calls /jobs, so wait for completed history only.
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
@@ -1000,7 +1002,9 @@ const MIXED_MEDIA_JOBS: RawJobListItem[] = [
|
||||
})
|
||||
]
|
||||
|
||||
// Filter button is guarded by isCloud; cloud CI needs authenticated setup.
|
||||
// Filter button is guarded by isCloud (compile-time). The cloud CI project
|
||||
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
|
||||
// supports authenticated comfyPage setup.
|
||||
test.describe('Assets sidebar - media type filter', () => {
|
||||
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
|
||||
|
||||
@@ -1036,9 +1040,12 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
'All three mixed-media jobs should render'
|
||||
).toHaveCount(3)
|
||||
|
||||
// Open filter menu and enable only image filter (selecting a filter
|
||||
// restricts to that type only, hiding unselected types)
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
|
||||
// Only the image asset should remain
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
|
||||
})
|
||||
@@ -1049,10 +1056,12 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Enable image filter to restrict to images only
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// Uncheck image filter to remove all filters (restores all assets)
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -214,7 +214,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.open()
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b1 selected\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
@@ -222,7 +222,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.getAssetCardByName('beta').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -233,64 +233,4 @@ test.describe('Model library sidebar - empty state', () => {
|
||||
await expect(tab.folderNodes).toHaveCount(0)
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - add node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking a model defers creation until placed on the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
|
||||
test('Ghost preview shows the model in the loader widget before placing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
const ghost = comfyPage.page.locator(
|
||||
'[data-node-id="preview-CheckpointLoaderSimple"]'
|
||||
)
|
||||
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { Locator, WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const KSAMPLER_NODE = '3'
|
||||
|
||||
async function runOnBackgroundTab(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute
|
||||
): Promise<{ exec: ExecutionHelper; jobId: string; backgroundTab: Locator }> {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await comfyPage.workflow.waitForActiveWorkflow()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
const jobId = await exec.run()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect(topbar.getActiveTab()).toContainText('(2)')
|
||||
|
||||
const backgroundTab = topbar.getTab(0)
|
||||
exec.executionStart(jobId)
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Running' })
|
||||
).toBeVisible()
|
||||
|
||||
return { exec, jobId, backgroundTab }
|
||||
}
|
||||
|
||||
test.describe('Workflow tab status indicator', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('replaces the running indicator with completed when the job finishes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionSuccess(jobId)
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Running' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows failed when the background job errors', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionError(jobId, KSAMPLER_NODE, 'boom')
|
||||
|
||||
// The error opens a modal dialog that aria-hides the rest of the app
|
||||
// (focus trap), taking the tab out of the accessibility tree. Dismiss it
|
||||
// so the badge is reachable by role.
|
||||
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(errorDialog).toBeHidden()
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Failed' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('drops the indicator on user interrupt rather than showing an error', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionInterrupted(jobId, KSAMPLER_NODE)
|
||||
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clears the indicator once the tab is activated', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionSuccess(jobId)
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
|
||||
const currentTab = comfyPage.menu.topbar.getActiveTab()
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
await backgroundTab.click()
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
|
||||
await currentTab.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -280,36 +280,3 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Vue Node Group Context Menu',
|
||||
{ tag: ['@vue-nodes', '@canvas'] },
|
||||
() => {
|
||||
test('right-clicking a group opens the Vue context menu instead of the legacy menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Deselect so the right-click selects the group itself.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => graph!.groups.length))
|
||||
.toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.click(groupPos.x, groupPos.y, {
|
||||
button: 'right'
|
||||
})
|
||||
|
||||
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
await expect(comfyPage.contextMenu.litegraphMenu).toBeHidden()
|
||||
|
||||
// Group-only action confirms it is the group menu.
|
||||
await expect(
|
||||
comfyPage.contextMenu.primeVueMenu.getByText('Fit Group To Nodes')
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.3",
|
||||
"version": "1.47.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
|
||||
})
|
||||
|
||||
it('does not call startDrag for folder items on dragstart', async () => {
|
||||
|
||||
@@ -44,30 +44,14 @@ describe('GlobalDialog renderer branching', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the Reka branch when renderer is omitted (default)', async () => {
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'renderer-default',
|
||||
title: 'Default renderer dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-escape-hatch',
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'primevue' }
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Dialog migration regression net: the showConfirmDialog helper must open
|
||||
* its dialog through the Reka renderer with zeroed section padding (the
|
||||
* Confirm* sections carry their own). Catches accidental reverts of the
|
||||
* Phase 6 renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
|
||||
describe('showConfirmDialog Reka renderer opt-in', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
|
||||
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
|
||||
showConfirmDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('md')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards the confirm section components and caller props', () => {
|
||||
showConfirmDialog({
|
||||
key: 'confirm-test',
|
||||
headerProps: { title: 'Title' },
|
||||
props: { promptText: 'Prompt' },
|
||||
footerProps: { confirmText: 'Delete' }
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('confirm-test')
|
||||
expect(args.headerComponent).toBe(ConfirmHeader)
|
||||
expect(args.component).toBe(ConfirmBody)
|
||||
expect(args.footerComponent).toBe(ConfirmFooter)
|
||||
expect(args.headerProps).toEqual({ title: 'Title' })
|
||||
expect(args.props).toEqual({ promptText: 'Prompt' })
|
||||
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
@@ -12,9 +11,7 @@ interface ConfirmDialogOptions {
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(
|
||||
options: ConfirmDialogOptions = {}
|
||||
): DialogInstance {
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
@@ -26,13 +23,11 @@ export function showConfirmDialog(
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'md',
|
||||
// Confirm sections carry their own padding — zero out the dialog
|
||||
// chrome padding, like the PrimeVue `pt` overrides did.
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0',
|
||||
footerClass: 'p-0'
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -39,23 +38,6 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: mockWorkspaceApi
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -136,8 +118,6 @@ describe('UsageLogsTable', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -340,20 +320,6 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.getBillingEvents).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 7
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
it('renders credit_added event with correct detail template', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
||||
|
||||
@@ -99,10 +99,7 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import {
|
||||
EventType,
|
||||
@@ -115,9 +112,6 @@ const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
@@ -144,13 +138,10 @@ const loadEvents = async () => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params = {
|
||||
const response = await customerEventService.getMyEvents({
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
}
|
||||
const response = useBillingApi.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
})
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<NodeDragPreview />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
@@ -137,7 +136,6 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
@@ -147,7 +145,6 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
@@ -467,7 +464,6 @@ useNodeBadge()
|
||||
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useGroupContextMenu()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue',
|
||||
() => ({
|
||||
default: { template: '<div data-testid="node-preview" />' }
|
||||
})
|
||||
)
|
||||
|
||||
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
|
||||
|
||||
function moveMouse(clientX: number, clientY: number) {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY }))
|
||||
}
|
||||
|
||||
function ghostElement() {
|
||||
return document.querySelector('[data-testid="node-preview"]')?.parentElement
|
||||
?.parentElement
|
||||
}
|
||||
|
||||
describe('NodeDragPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows no ghost when nothing is being dragged', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('keeps the ghost hidden until the mouse position is known', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('follows the mouse with an offset while dragging', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
moveMouse(300, 400)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(312px, 412px)')
|
||||
})
|
||||
|
||||
it('removes the ghost when the drag is cancelled', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
expect(ghostElement()).toBeTruthy()
|
||||
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showGhost && rafPosition"
|
||||
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
|
||||
:style="{
|
||||
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview
|
||||
:node-def="draggedNode!"
|
||||
:widget-values="pendingWidgetValues"
|
||||
position="relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouse, useRafFn } from '@vueuse/core'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
|
||||
|
||||
const { x, y, sourceType } = useMouse({ type: 'client' })
|
||||
|
||||
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
|
||||
const rafPosition = shallowRef<{ x: number; y: number }>()
|
||||
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
if (sourceType.value === null) return
|
||||
const pos = rafPosition.value
|
||||
if (pos && pos.x === x.value && pos.y === y.value) return
|
||||
rafPosition.value = { x: x.value, y: y.value }
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
showGhost,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
rafPosition.value = undefined
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -66,6 +66,7 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -194,15 +195,20 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const jobId = item.taskRef?.jobId
|
||||
if (!jobId) return
|
||||
|
||||
if (
|
||||
item.state === 'running' ||
|
||||
item.state === 'initialization' ||
|
||||
item.state === 'pending'
|
||||
) {
|
||||
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
|
||||
await api.cancelJob(jobId)
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
// Running/initializing jobs: interrupt execution
|
||||
// Cloud backend uses deleteItem, local uses interrupt
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', jobId)
|
||||
} else {
|
||||
await api.interrupt(jobId)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(jobId)
|
||||
await queueStore.update()
|
||||
} else if (item.state === 'pending') {
|
||||
// Pending jobs: remove from queue
|
||||
await api.deleteItem('queue', jobId)
|
||||
await queueStore.update()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -286,8 +292,17 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
if (!jobIds.length) return
|
||||
|
||||
// State-agnostic batch cancel (see api.ts cancelJobs for the runtime-parity caveat).
|
||||
await api.cancelJobs(jobIds)
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(jobIds.map((id) => api.interrupt(id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
})
|
||||
|
||||
@@ -115,14 +115,69 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<MediaAssetSelectionBar
|
||||
<div
|
||||
v-if="hasSelection"
|
||||
:count="totalOutputCount"
|
||||
:show-delete="shouldShowDeleteButton"
|
||||
@deselect="handleDeselectAll"
|
||||
@download="handleDownloadSelected"
|
||||
@delete="handleDeleteSelected"
|
||||
/>
|
||||
ref="footerRef"
|
||||
class="flex h-18 w-full items-center justify-between gap-1"
|
||||
>
|
||||
<div class="flex-1 pl-4">
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<Button
|
||||
variant="secondary"
|
||||
:class="cn(isCompact && 'text-left')"
|
||||
@click="handleDeselectAll"
|
||||
>
|
||||
{{
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink items-center-safe justify-end-safe gap-2 pr-4">
|
||||
<template v-if="isCompact">
|
||||
<!-- Compact mode: Icon only -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Normal mode: Icon + Text -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<MediaLightbox
|
||||
@@ -153,6 +208,8 @@
|
||||
import {
|
||||
useAsyncState,
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
@@ -179,7 +236,6 @@ import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
@@ -201,6 +257,7 @@ import {
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
@@ -278,6 +335,33 @@ const {
|
||||
exportMultipleWorkflows
|
||||
} = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
const footerWidth = ref(0)
|
||||
|
||||
// Track footer width changes
|
||||
useResizeObserver(footerRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
footerWidth.value = entry.contentRect.width
|
||||
})
|
||||
|
||||
// Determine if we should show compact mode (icon only)
|
||||
// Threshold matches when grid switches from 2 columns to 1 column
|
||||
// 2 columns need about ~430px
|
||||
const COMPACT_MODE_THRESHOLD_PX = 430
|
||||
const isCompact = computed(
|
||||
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
|
||||
)
|
||||
|
||||
// Hover state for selection count button
|
||||
const selectionCountButtonRef = ref<HTMLElement | null>(null)
|
||||
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
)
|
||||
@@ -345,10 +429,6 @@ const previewableVisibleAssets = computed(() =>
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const totalOutputCount = computed(() =>
|
||||
getTotalOutputCount(selectedAssets.value)
|
||||
)
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ const {
|
||||
captureRoot,
|
||||
getRoot,
|
||||
resetRoot,
|
||||
mockStartDrag,
|
||||
mockAddNodeOnGraph,
|
||||
mockGetNodeProvider,
|
||||
mockToggleNodeOnEvent,
|
||||
mockRefreshModelFolder,
|
||||
@@ -29,7 +29,7 @@ const {
|
||||
resetRoot: () => {
|
||||
capturedRoot = null
|
||||
},
|
||||
mockStartDrag: vi.fn(),
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -37,8 +37,8 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
@@ -173,13 +173,16 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
expect(screen.getByTestId('search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
|
||||
it('handles model click and adds node to graph', async () => {
|
||||
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
const mockWidget = { name: 'ckpt_name', value: '' }
|
||||
const mockGraphNode = { widgets: [mockWidget] }
|
||||
|
||||
mockGetNodeProvider.mockReturnValue({
|
||||
nodeDef: mockNodeDef,
|
||||
key: 'ckpt_name'
|
||||
})
|
||||
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
@@ -195,10 +198,8 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
await modelLeaf?.handleClick?.(mockEvent)
|
||||
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' },
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
|
||||
expect(mockWidget.value).toBe('model.safetensors')
|
||||
})
|
||||
|
||||
it('toggles folder expansion on click', async () => {
|
||||
|
||||
@@ -63,9 +63,10 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
import { ResourceState, useModelStore } from '@/stores/modelStore'
|
||||
@@ -155,7 +156,15 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
startModelLoaderDrag(provider, model.file_name)
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
)
|
||||
if (widget) {
|
||||
widget.value = model.file_name
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
|
||||
@@ -31,8 +31,11 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
isDragging: { value: false },
|
||||
draggedNode: { value: null },
|
||||
cursorPosition: { value: { x: 0, y: 0 } },
|
||||
startDrag: vi.fn(),
|
||||
cancelDrag: vi.fn()
|
||||
cancelDrag: vi.fn(),
|
||||
setupGlobalListeners: vi.fn(),
|
||||
cleanupGlobalListeners: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeDragPreview />
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="hasNoMatches"
|
||||
@@ -214,6 +215,7 @@ import type {
|
||||
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
|
||||
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
|
||||
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
|
||||
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
69
src/components/sidebar/tabs/nodeLibrary/NodeDragPreview.vue
Normal file
69
src/components/sidebar/tabs/nodeLibrary/NodeDragPreview.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isDragging && draggedNode && showPreview"
|
||||
class="pointer-events-none fixed z-10000"
|
||||
:style="{
|
||||
left: `${previewPosition.x + 12}px`,
|
||||
top: `${previewPosition.y + 12}px`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview :node-def="draggedNode" position="relative" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
} = useNodeDragToCanvas()
|
||||
|
||||
const nativeDragPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
const previewPosition = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value
|
||||
}
|
||||
return cursorPosition.value
|
||||
})
|
||||
|
||||
const showPreview = computed(() => {
|
||||
if (dragMode.value === 'native') {
|
||||
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
function handleDrag(e: DragEvent) {
|
||||
if (e.clientX === 0 && e.clientY === 0) return
|
||||
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
nativeDragPosition.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupGlobalListeners()
|
||||
document.addEventListener('drag', handleDrag)
|
||||
document.addEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupGlobalListeners()
|
||||
document.removeEventListener('drag', handleDrag)
|
||||
document.removeEventListener('dragend', handleDragEnd)
|
||||
})
|
||||
</script>
|
||||
@@ -1,233 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { markRaw } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import type * as ExecutionStoreModule from '@/stores/executionStore'
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
|
||||
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
mockWorkflowStatus: shallowRef<Map<object, WorkflowExecutionStatus>>(
|
||||
new Map()
|
||||
),
|
||||
mockCloseWorkflow: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ExecutionStoreModule>()
|
||||
return {
|
||||
WORKFLOW_STATUS_I18N_KEYS: actual.WORKFLOW_STATUS_I18N_KEYS,
|
||||
useExecutionStore: () => ({
|
||||
getWorkflowStatus(workflow: object | undefined | null) {
|
||||
if (!workflow) return undefined
|
||||
return mockWorkflowStatus.value.get(workflow)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
|
||||
usePragmaticDraggable: vi.fn(),
|
||||
usePragmaticDroppable: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
|
||||
useWorkflowActionsMenu: () => ({
|
||||
menuItems: { value: [] }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
closeWorkflow: mockCloseWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
getThumbnail: vi.fn(() => null)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowTabPopover.vue', () => ({
|
||||
default: {
|
||||
render: () => null,
|
||||
methods: {
|
||||
showPopover: () => {},
|
||||
hidePopover: () => {},
|
||||
togglePopover: () => {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import WorkflowTab from './WorkflowTab.vue'
|
||||
|
||||
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
|
||||
|
||||
const statusAriaLabels: Record<WorkflowExecutionStatus, string> = {
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed'
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { close: 'Close', ...statusAriaLabels }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type WorkflowOption = WorkflowTabProps['workflowOption']
|
||||
type Workflow = WorkflowOption['workflow']
|
||||
type WorkflowOverrides = Partial<Workflow>
|
||||
|
||||
// ComfyWorkflow has many required fields the component never reads (file
|
||||
// IO, change tracking). Validate the fields we *do* set against the real
|
||||
// type via Partial<Workflow>, then cast — adding/renaming a read field in
|
||||
// the component will fail typecheck on the override map.
|
||||
function makeWorkflowOption(overrides: WorkflowOverrides = {}): WorkflowOption {
|
||||
const workflow = {
|
||||
key: 'test-key',
|
||||
path: '/workflows/test.json',
|
||||
filename: 'test.json',
|
||||
isPersisted: true,
|
||||
isModified: false,
|
||||
activeMode: 'graph',
|
||||
changeTracker: null,
|
||||
...overrides
|
||||
} satisfies WorkflowOverrides
|
||||
// markRaw keeps a stable identity through prop reactivity so the store's
|
||||
// identity-based status lookup resolves against the same object.
|
||||
return { value: 'test-key', workflow: markRaw(workflow) as Workflow }
|
||||
}
|
||||
|
||||
function renderTab({
|
||||
workflowOption = makeWorkflowOption(),
|
||||
activeWorkflowKey = 'other-key'
|
||||
}: {
|
||||
workflowOption?: WorkflowOption
|
||||
activeWorkflowKey?: string
|
||||
} = {}) {
|
||||
return render(WorkflowTab, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
workspace: { shiftDown: false },
|
||||
workflow: {
|
||||
activeWorkflow: { key: activeWorkflowKey }
|
||||
},
|
||||
setting: { settingValues: { 'Comfy.Workflow.AutoSave': 'off' } }
|
||||
}
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
WorkflowActionsList: true,
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
workflowOption,
|
||||
isFirst: false,
|
||||
isLast: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkflowTab - workflow status indicator', () => {
|
||||
beforeEach(() => {
|
||||
mockWorkflowStatus.value = new Map()
|
||||
})
|
||||
|
||||
it.for(['running', 'completed', 'failed'] as const)(
|
||||
'labels the %s indicator with a translated status name',
|
||||
(status) => {
|
||||
const workflowOption = makeWorkflowOption()
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, status]])
|
||||
|
||||
renderTab({ workflowOption })
|
||||
expect(
|
||||
screen.getByRole('img', { name: statusAriaLabels[status] })
|
||||
).toBeTruthy()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not badge the active tab with its own status', () => {
|
||||
const workflowOption = makeWorkflowOption()
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
|
||||
|
||||
renderTab({ workflowOption, activeWorkflowKey: 'test-key' })
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows unsaved dot when no workflow status and workflow is unsaved', () => {
|
||||
renderTab({ workflowOption: makeWorkflowOption({ isPersisted: false }) })
|
||||
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
|
||||
})
|
||||
|
||||
it('shows the unsaved dot when modified and autosave is off', () => {
|
||||
renderTab({ workflowOption: makeWorkflowOption({ isModified: true }) })
|
||||
|
||||
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
|
||||
})
|
||||
|
||||
it('workflow status replaces the unsaved dot', () => {
|
||||
const workflowOption = makeWorkflowOption({ isPersisted: false })
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
|
||||
|
||||
renderTab({ workflowOption })
|
||||
expect(
|
||||
screen.getByRole('img', { name: statusAriaLabels.running })
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByTestId('workflow-dirty-indicator')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkflowTab - close button', () => {
|
||||
beforeEach(() => {
|
||||
mockCloseWorkflow.mockClear()
|
||||
})
|
||||
|
||||
it('delegates close to workflow service with the tab workflow', async () => {
|
||||
renderTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('close-workflow-button'))
|
||||
|
||||
expect(mockCloseWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'test-key' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,19 +21,8 @@
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<i
|
||||
v-if="workflowStatus"
|
||||
role="img"
|
||||
:aria-label="workflowStatusLabel"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
|
||||
workflowStatusIconClasses[workflowStatus]
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-else-if="shouldShowUnsavedIndicator"
|
||||
v-if="shouldShowStatusIndicator"
|
||||
data-testid="workflow-dirty-indicator"
|
||||
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
|
||||
>•</span
|
||||
@@ -43,7 +32,6 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
data-testid="close-workflow-button"
|
||||
@click.stop="onCloseWorkflow(workflowOption)"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
@@ -97,14 +85,8 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
import {
|
||||
useExecutionStore,
|
||||
WORKFLOW_STATUS_I18N_KEYS
|
||||
} from '@/stores/executionStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
@@ -131,7 +113,6 @@ const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
@@ -144,7 +125,7 @@ const autoSaveDelay = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSaveDelay')
|
||||
)
|
||||
|
||||
const shouldShowUnsavedIndicator = computed(() => {
|
||||
const shouldShowStatusIndicator = computed(() => {
|
||||
if (workspaceStore.shiftDown) {
|
||||
// Branch 1: Shift key is held down, do not show the status indicator.
|
||||
return false
|
||||
@@ -179,27 +160,6 @@ const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
const workflowStatusIconClasses: Record<WorkflowExecutionStatus, string> = {
|
||||
running:
|
||||
'text-base-foreground icon-[lucide--loader-circle] motion-safe:animate-spin',
|
||||
completed: 'icon-[lucide--circle-check] text-success-background',
|
||||
failed: 'icon-[lucide--octagon-alert] text-destructive-background'
|
||||
}
|
||||
|
||||
// The active tab doesn't badge its own status - the user is already looking
|
||||
// at it. Background tabs surface the recorded execution status.
|
||||
const workflowStatus = computed(() =>
|
||||
isActiveTab.value
|
||||
? undefined
|
||||
: executionStore.getWorkflowStatus(props.workflowOption.workflow)
|
||||
)
|
||||
|
||||
const workflowStatusLabel = computed(() =>
|
||||
workflowStatus.value
|
||||
? t(WORKFLOW_STATUS_I18N_KEYS[workflowStatus.value])
|
||||
: undefined
|
||||
)
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
@@ -43,10 +43,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowStatusDismissal', () => ({
|
||||
useWorkflowStatusDismissal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
isOverflowing: { value: false },
|
||||
|
||||
@@ -117,7 +117,6 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
@@ -146,9 +145,6 @@ const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
useWorkflowStatusDismissal()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const meta: Meta<typeof CreditSlider> = {
|
||||
title: 'Components/CreditSlider',
|
||||
component: CreditSlider,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
// Previews at the real layout width: the Figma "Team Plan" card column is
|
||||
// 512px wide with 32px padding (DES-197), i.e. a 448px content area — the
|
||||
// width the slider actually renders into inside PricingTableWorkspace.
|
||||
template: '<div class="w-[512px] px-8"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const value = ref(700)
|
||||
return { args, value }
|
||||
},
|
||||
template: '<CreditSlider v-model="value" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
|
||||
// Sample `GET /api/billing/plans → team_credit_stops` payload (DES-197 yearly).
|
||||
// In production this comes from the API; here it shows the stops being driven
|
||||
// entirely through props rather than the hardcoded default constant.
|
||||
const apiTeamCreditStops = {
|
||||
default_stop_index: 2,
|
||||
stops: [
|
||||
{
|
||||
id: 'team_200',
|
||||
credits: 42_200,
|
||||
yearly: { price_cents: 20_000, discount_percent: 0 }
|
||||
},
|
||||
{
|
||||
id: 'team_400',
|
||||
credits: 84_400,
|
||||
yearly: { price_cents: 38_000, discount_percent: 5 }
|
||||
},
|
||||
{
|
||||
id: 'team_700',
|
||||
credits: 147_700,
|
||||
yearly: { price_cents: 63_000, discount_percent: 10 }
|
||||
},
|
||||
{
|
||||
id: 'team_1400',
|
||||
credits: 295_400,
|
||||
yearly: { price_cents: 119_000, discount_percent: 15 }
|
||||
},
|
||||
{
|
||||
id: 'team_2500',
|
||||
credits: 527_500,
|
||||
yearly: { price_cents: 200_000, discount_percent: 20 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Reference adapter (FE-934 will own this in the data layer): API → CreditStop[].
|
||||
// The pre-discount list price is recovered as discounted / (1 - discount).
|
||||
const mappedStops = apiTeamCreditStops.stops.map((s) => ({
|
||||
credits: s.credits,
|
||||
discountPercentYearly: s.yearly.discount_percent,
|
||||
usd: Math.round(
|
||||
s.yearly.price_cents / 100 / (1 - s.yearly.discount_percent / 100)
|
||||
)
|
||||
}))
|
||||
|
||||
export const BackendDrivenStops: Story = {
|
||||
name: 'Backend-driven stops (props)',
|
||||
render: (args) => ({
|
||||
components: { CreditSlider },
|
||||
setup() {
|
||||
const defaultStopIndex = apiTeamCreditStops.default_stop_index
|
||||
const value = ref(mappedStops[defaultStopIndex].usd)
|
||||
return { args, value, mappedStops, defaultStopIndex }
|
||||
},
|
||||
template:
|
||||
'<CreditSlider v-model="value" :stops="mappedStops" :default-stop-index="defaultStopIndex" :disabled="args.disabled" />'
|
||||
})
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import { TEAM_PLAN_CREDIT_STOPS } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
import CreditSlider from './CreditSlider.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
usdPerMonth: 'USD / mo',
|
||||
billedYearly: '{total} Billed yearly',
|
||||
billedMonthly: 'Billed monthly',
|
||||
creditSliderSave: 'Save {percent}% ({amount})'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderSlider(props: Record<string, unknown> = {}) {
|
||||
return render(CreditSlider, { props, global: { plugins: [i18n] } })
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('CreditSlider', () => {
|
||||
it('defaults to the $700 stop (index 2) when no value is bound', async () => {
|
||||
renderSlider()
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '4')
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
|
||||
it('snaps to the next fixed stop on ArrowRight (never a value in between)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(1400)
|
||||
})
|
||||
|
||||
it('snaps to the previous fixed stop on ArrowLeft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
|
||||
renderSlider({ modelValue: 700, 'onUpdate:modelValue': onUpdate })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith(400)
|
||||
})
|
||||
|
||||
it('emits change with the full {index, usd, credits} payload', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({ modelValue: 700, onChange })
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
index: 3,
|
||||
usd: 1400,
|
||||
credits: 295_400
|
||||
})
|
||||
})
|
||||
|
||||
it('emits nothing when disabled (keyboard interaction suppressed)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(usd: number) => void>()
|
||||
const onChange = vi.fn()
|
||||
|
||||
renderSlider({
|
||||
modelValue: 700,
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': onUpdate,
|
||||
onChange
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the discounted price, struck original, save badge and yearly total (DES-197)', async () => {
|
||||
renderSlider() // default $700 stop → 10% yearly discount
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$630')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 10% ($70)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'$7,560'
|
||||
)
|
||||
})
|
||||
|
||||
it('halves the discount and reads "billed monthly" when cycle=monthly (PRD)', async () => {
|
||||
renderSlider({ cycle: 'monthly' }) // default $700 stop → 10% yearly → 5% monthly
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$665')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$700')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 5% ($35)'
|
||||
)
|
||||
expect(screen.getByTestId('credit-slider-billed-yearly')).toHaveTextContent(
|
||||
'Billed monthly'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the fractional monthly discount at $400 (2.5%)', async () => {
|
||||
renderSlider({ modelValue: 400, cycle: 'monthly' })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$390')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 2.5% ($10)'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the discount UI at the 0% stop ($200)', async () => {
|
||||
renderSlider({ modelValue: 200 })
|
||||
await flush()
|
||||
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$200')
|
||||
expect(
|
||||
screen.queryByTestId('credit-slider-original-price')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('credit-slider-save')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all five fixed credit stop labels', async () => {
|
||||
renderSlider({ modelValue: 700 })
|
||||
await flush()
|
||||
|
||||
const stops = within(screen.getByTestId('credit-slider-stops'))
|
||||
for (const label of ['42.2K', '84.4K', '147.7K', '295.4K', '527.5K']) {
|
||||
expect(stops.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders stops + default index supplied via props (BE-sourced override)', async () => {
|
||||
const stops = [
|
||||
{ usd: 50, credits: 10_550, discountPercentYearly: 0 },
|
||||
{ usd: 100, credits: 21_100, discountPercentYearly: 25 }
|
||||
]
|
||||
// No modelValue → the model default ($700) matches no stop, so selectedIndex
|
||||
// falls back to defaultStopIndex (here index 1 → $100).
|
||||
renderSlider({ stops, defaultStopIndex: 1 })
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '1') // 2 stops → max index 1
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '1') // default index honored
|
||||
|
||||
// index 1 → $100 at 25% yearly → $75 discounted, struck $100, save $25
|
||||
expect(screen.getByTestId('credit-slider-price')).toHaveTextContent('$75')
|
||||
expect(
|
||||
screen.getByTestId('credit-slider-original-price')
|
||||
).toHaveTextContent('$100')
|
||||
expect(screen.getByTestId('credit-slider-save')).toHaveTextContent(
|
||||
'Save 25% ($25)'
|
||||
)
|
||||
|
||||
// Only the prop's labels render — none of the DES-197 defaults.
|
||||
const labels = within(screen.getByTestId('credit-slider-stops'))
|
||||
expect(labels.getByText('10.6K')).toBeInTheDocument()
|
||||
expect(labels.getByText('21.1K')).toBeInTheDocument()
|
||||
expect(labels.queryByText('147.7K')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps every credit amount equal to usdToCredits(usd) (guards rate drift)', () => {
|
||||
for (const stop of TEAM_PLAN_CREDIT_STOPS) {
|
||||
expect(stop.credits).toBe(usdToCredits(stop.usd))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,235 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TransitionPresets,
|
||||
usePreferredReducedMotion,
|
||||
useTransition
|
||||
} from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
TEAM_PLAN_CREDIT_STOPS
|
||||
} from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import type { CreditStop } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
class: rootClass,
|
||||
stops = TEAM_PLAN_CREDIT_STOPS,
|
||||
defaultStopIndex = DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
cycle = 'yearly'
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
/**
|
||||
* The fixed credit stops the slider snaps to. Must be non-empty. Defaults to
|
||||
* the hardcoded DES-197 set; pass the backend-sourced stops once the contract
|
||||
* lands — map `GET /api/billing/plans → team_credit_stops.stops` to
|
||||
* `CreditStop[]` (credits, the pre-discount `usd`, and `discountPercentYearly`).
|
||||
*/
|
||||
stops?: readonly CreditStop[]
|
||||
/**
|
||||
* Stop selected when the bound value matches none (e.g. first render).
|
||||
* Maps to `team_credit_stops.default_stop_index`. Defaults to DES-197 ($700).
|
||||
*/
|
||||
defaultStopIndex?: number
|
||||
/**
|
||||
* Billing cycle. Yearly applies the full `discountPercentYearly`; monthly
|
||||
* applies half of it (PRD: GA Team Billing — "for monthly the discount is
|
||||
* halved": yearly 0/5/10/15/20% → monthly 0/2.5/5/7.5/10%).
|
||||
*/
|
||||
cycle?: 'monthly' | 'yearly'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Fired when the selected stop changes, with the full derived payload. */
|
||||
change: [stop: { index: number; usd: number; credits: number }]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* v-model carries the selected USD value (one of the `stops`). The literal
|
||||
* default keeps `defineModel` statically analyzable; when custom `stops` are
|
||||
* passed without a matching v-model, `selectedIndex` falls back to
|
||||
* `defaultStopIndex`, so the displayed stop is still correct.
|
||||
*/
|
||||
const usd = defineModel<number>({
|
||||
default: TEAM_PLAN_CREDIT_STOPS[DEFAULT_TEAM_PLAN_STOP_INDEX].usd
|
||||
})
|
||||
|
||||
const selectedIndex = computed(() => {
|
||||
const i = stops.findIndex((stop) => stop.usd === usd.value)
|
||||
if (i !== -1) return i
|
||||
// Fall back to the default stop, clamped into range: a backend-driven `stops`
|
||||
// array can be shorter than expected (or `defaultStopIndex` out of bounds), so
|
||||
// clamping keeps `current` defined and the price computeds below from reading
|
||||
// `undefined.usd` at runtime. (`stops` is required to be non-empty.)
|
||||
return Math.min(Math.max(defaultStopIndex, 0), Math.max(stops.length - 1, 0))
|
||||
})
|
||||
|
||||
const current = computed<CreditStop>(() => stops[selectedIndex.value])
|
||||
|
||||
// The discount applies to the monthly figure. Yearly uses the full
|
||||
// `discountPercentYearly`; monthly halves it (PRD: GA Team Billing). The card
|
||||
// shows the discounted monthly price, the struck pre-discount price, the
|
||||
// saving, and — for yearly — the annual total.
|
||||
const effectiveDiscountPercent = computed(() =>
|
||||
cycle === 'monthly'
|
||||
? current.value.discountPercentYearly / 2
|
||||
: current.value.discountPercentYearly
|
||||
)
|
||||
const discountedMonthly = computed(() =>
|
||||
Math.round(current.value.usd * (1 - effectiveDiscountPercent.value / 100))
|
||||
)
|
||||
const saveAmount = computed(() => current.value.usd - discountedMonthly.value)
|
||||
const hasDiscount = computed(() => effectiveDiscountPercent.value > 0)
|
||||
|
||||
/**
|
||||
* Smoothly count the price figures up/down as the slider moves between stops
|
||||
* instead of snapping. Honors the user's reduced-motion preference. The save
|
||||
* badge ("X% ($Y)") is intentionally left snapping — its percent is a discrete
|
||||
* tier, so animating the bracketed amount alone would read inconsistently.
|
||||
*/
|
||||
const prefersReducedMotion = usePreferredReducedMotion()
|
||||
const priceTween = {
|
||||
duration: 350,
|
||||
easing: TransitionPresets.easeOutCubic,
|
||||
disabled: computed(() => prefersReducedMotion.value === 'reduce')
|
||||
}
|
||||
const animatedMonthly = useTransition(discountedMonthly, priceTween)
|
||||
const animatedOriginal = useTransition(() => current.value.usd, priceTween)
|
||||
|
||||
const displayMonthly = computed(() => Math.round(animatedMonthly.value))
|
||||
const displayOriginal = computed(() => Math.round(animatedOriginal.value))
|
||||
// Derive the yearly total from the displayed monthly so it always reads as
|
||||
// exactly 12× the price shown — even mid-count — rather than drifting as a
|
||||
// second, independently-phased tween would.
|
||||
const displayBilledYearly = computed(() => displayMonthly.value * 12)
|
||||
|
||||
/**
|
||||
* Bridge the discrete stop index (0..n-1) to the reka-ui slider's `number[]`
|
||||
* model. Driving the slider in index space with `step = 1` guarantees the
|
||||
* thumb can only land on the fixed stops — never a value in between.
|
||||
*/
|
||||
const sliderModel = computed<number[]>({
|
||||
get: () => [selectedIndex.value],
|
||||
set: ([index]) => {
|
||||
const stop = stops[index]
|
||||
if (!stop) return
|
||||
usd.value = stop.usd
|
||||
emit('change', { index, usd: stop.usd, credits: stop.credits })
|
||||
}
|
||||
})
|
||||
|
||||
const lastIndex = computed(() => Math.max(stops.length - 1, 0))
|
||||
|
||||
const formatUsd = (value: number) => `$${value.toLocaleString('en-US')}`
|
||||
const formatCreditsCompact = (value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1
|
||||
}).format(value)
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex w-full flex-col gap-3', rootClass)">
|
||||
<!-- Price: discounted monthly + struck pre-discount + save badge -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[2rem]/none font-semibold text-base-foreground tabular-nums"
|
||||
data-testid="credit-slider-price"
|
||||
>
|
||||
{{ formatUsd(displayMonthly) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
class="text-base text-muted-foreground tabular-nums line-through"
|
||||
data-testid="credit-slider-original-price"
|
||||
>
|
||||
{{ formatUsd(displayOriginal) }}
|
||||
</span>
|
||||
<span class="text-base text-muted-foreground">
|
||||
{{ t('subscription.usdPerMonth') }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- Save badge: outlined primary pill. On wide layouts it's pushed to
|
||||
the right of the price; when the column narrows (mobile) it wraps
|
||||
and aligns left under the price instead (DES QA). -->
|
||||
<span
|
||||
v-if="hasDiscount"
|
||||
data-testid="credit-slider-save"
|
||||
class="shrink-0 rounded-full border-2 border-primary-background px-2 py-1 text-sm font-bold whitespace-nowrap text-primary-background xl:ms-auto"
|
||||
>
|
||||
{{
|
||||
t('subscription.creditSliderSave', {
|
||||
percent: effectiveDiscountPercent,
|
||||
amount: formatUsd(saveAmount)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="m-0 text-sm text-muted-foreground tabular-nums"
|
||||
data-testid="credit-slider-billed-yearly"
|
||||
>
|
||||
{{
|
||||
cycle === 'monthly'
|
||||
? t('subscription.billedMonthly')
|
||||
: t('subscription.billedYearly', {
|
||||
total: formatUsd(displayBilledYearly)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Discrete slider: snaps to the 5 fixed DES-197 stops -->
|
||||
<Slider
|
||||
v-model="sliderModel"
|
||||
:min="0"
|
||||
:max="lastIndex"
|
||||
:step="1"
|
||||
:disabled="disabled"
|
||||
range-class="bg-base-foreground"
|
||||
thumb-class="bg-base-foreground"
|
||||
/>
|
||||
|
||||
<!-- Credit stop labels; the selected stop is emphasized -->
|
||||
<ol
|
||||
data-testid="credit-slider-stops"
|
||||
class="m-0 flex list-none justify-between p-0"
|
||||
>
|
||||
<li
|
||||
v-for="(stop, i) in stops"
|
||||
:key="stop.usd"
|
||||
:data-selected="i === selectedIndex ? '' : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1 text-xs tabular-nums',
|
||||
i === selectedIndex
|
||||
? 'font-semibold text-base-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatCreditsCompact(stop.credits) }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,11 +15,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const props = defineProps<
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
SliderRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
rangeClass?: HTMLAttributes['class']
|
||||
thumbClass?: HTMLAttributes['class']
|
||||
}
|
||||
SliderRootProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const pressed = ref(false)
|
||||
@@ -29,7 +25,7 @@ const setPressed = (val: boolean) => {
|
||||
|
||||
const emits = defineEmits<SliderRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'rangeClass', 'thumbClass')
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -64,12 +60,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
>
|
||||
<SliderRange
|
||||
data-slot="slider-range"
|
||||
:class="
|
||||
cn(
|
||||
'absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
|
||||
props.rangeClass
|
||||
)
|
||||
"
|
||||
class="absolute bg-node-component-surface-highlight data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
/>
|
||||
</SliderTrack>
|
||||
|
||||
@@ -83,8 +74,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
'cursor-grab',
|
||||
'before:absolute before:-inset-1 before:block before:rounded-full before:bg-transparent',
|
||||
'hover:ring-2 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
|
||||
{ 'cursor-grabbing': pressed },
|
||||
props.thumbClass
|
||||
{ 'cursor-grabbing': pressed }
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -225,40 +225,6 @@ describe('useAuthActions.reportError', () => {
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the signupBlocked message when the error carries the signup_blocked token', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
// The backend wraps the rejection in a generic code; we match the token in
|
||||
// the message, so it must win over the auth.errors.${code} fallback.
|
||||
reportError(
|
||||
new FirebaseError(
|
||||
'auth/internal-error',
|
||||
'Account creation is temporarily unavailable. (ref: signup_blocked)'
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.signupBlocked'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches the signup_blocked token case-insensitively', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(
|
||||
new FirebaseError('auth/internal-error', 'rejected: SIGNUP_BLOCKED')
|
||||
)
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.signupBlocked'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
|
||||
@@ -47,19 +47,6 @@ export const useAuthActions = () => {
|
||||
email: 'support@comfy.org'
|
||||
})
|
||||
})
|
||||
} else if (
|
||||
error instanceof FirebaseError &&
|
||||
error.message.toLowerCase().includes('signup_blocked')
|
||||
) {
|
||||
// Match on `error.message`, not `error.code`: Firebase `beforeUserCreated`
|
||||
// rejections collapse the thrown code into a generic `auth/internal-error`,
|
||||
// so the message is the only reliable channel. `signup_blocked` is a
|
||||
// cross-repo contract token; matched case-insensitively.
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('auth.errors.signupBlocked')
|
||||
})
|
||||
} else if (error instanceof FirebaseError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
|
||||
@@ -5,13 +5,11 @@ import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
CreateTopupResponse,
|
||||
CurrentTeamCreditStop,
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
TeamCreditStops
|
||||
SubscriptionTier
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export type BillingType = 'legacy' | 'workspace'
|
||||
@@ -73,10 +71,6 @@ export interface BillingState {
|
||||
balance: ComputedRef<BalanceInfo | null>
|
||||
plans: ComputedRef<Plan[]>
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
/** Team per-credit pricing ladder; null for personal/legacy. */
|
||||
teamCreditStops: ComputedRef<TeamCreditStops | null>
|
||||
/** The team's currently-subscribed credit stop; null for personal/legacy. */
|
||||
currentTeamCreditStop: ComputedRef<CurrentTeamCreditStop | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
@@ -89,10 +83,5 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
}
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Plan
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
mockBillingStatus
|
||||
mockPurchaseCredits
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
mockBillingStatus: {
|
||||
value: {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
} as BillingStatusResponse
|
||||
}
|
||||
mockPurchaseCredits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
@@ -122,7 +103,12 @@ vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getBillingStatus: vi.fn(() => Promise.resolve(mockBillingStatus.value)),
|
||||
getBillingStatus: vi.fn().mockResolvedValue({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}),
|
||||
getBillingBalance: vi.fn().mockResolvedValue({
|
||||
amount_micros: 10000000,
|
||||
currency: 'usd'
|
||||
@@ -139,7 +125,6 @@ describe('useBillingContext', () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
@@ -267,158 +252,4 @@ describe('useBillingContext', () => {
|
||||
expect(getMaxSeats('creator')).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLegacyTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isLegacyTeamPlan } = useBillingContext()
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for an active team plan: team- slug and no credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for any legacy team tier, not just standard', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-pro-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a new credit-slider team subscriber', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
// Real BE shape: underscore slug + populated credit stop. (subscription_tier
|
||||
// is 'TEAM' on the wire, not yet in the FE SubscriptionTier union, so it is
|
||||
// omitted here — the predicate does not depend on it.)
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team_per_credit_annual',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 147700,
|
||||
stop_usd: 700
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a new team sub even before its credit stop is populated', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
// Provisioning lag: credit stop not yet attached. The underscore slug
|
||||
// (team_per_credit, not team-) must still exclude it from the legacy table.
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team_per_credit_annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a team workspace on a personal-tier plan', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stays true for a cancelled-but-still-active legacy team sub', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'canceled',
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'team-standard-annual',
|
||||
cancel_at: '2099-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a FREE-tier team even on a team- prefixed slug', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'FREE',
|
||||
plan_slug: 'team-free'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
it('matches the legacy slug case-insensitively', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'ANNUAL',
|
||||
plan_slug: 'Team-Standard-Annual'
|
||||
}
|
||||
|
||||
const { initialize, isLegacyTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,12 +20,6 @@ import type {
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
// Legacy per-member team plans use a hyphenated `team-{tier}-{cycle}` slug; the
|
||||
// new credit-slider plan uses an underscore `team_per_credit_{cycle}` slug and
|
||||
// carries a team_credit_stop. The hyphen prefix alone separates the two, so a
|
||||
// new sub is never misrouted even before its credit stop is populated.
|
||||
const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
@@ -122,32 +116,12 @@ function useBillingContextInternal(): BillingContext {
|
||||
toValue(activeContext.value.currentPlanSlug)
|
||||
)
|
||||
|
||||
const teamCreditStops = computed(() =>
|
||||
toValue(activeContext.value.teamCreditStops)
|
||||
)
|
||||
|
||||
const currentTeamCreditStop = computed(() =>
|
||||
toValue(activeContext.value.currentTeamCreditStop)
|
||||
)
|
||||
|
||||
const isActiveSubscription = computed(() =>
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const isLegacyTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
isActiveSubscription.value &&
|
||||
!isFreeTier.value &&
|
||||
currentTeamCreditStop.value === null &&
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false)
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -280,13 +254,10 @@ function useBillingContextInternal(): BillingContext {
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -93,8 +93,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
const teamCreditStops = computed(() => null)
|
||||
const currentTeamCreditStop = computed(() => null)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
@@ -202,8 +200,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
teamCreditStops,
|
||||
currentTeamCreditStop,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
@@ -15,14 +15,7 @@ export interface PositionConfig {
|
||||
scale?: number
|
||||
}
|
||||
|
||||
interface UseAbsolutePositionReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updatePosition: (config: PositionConfig) => void
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(
|
||||
options: { useTransform?: boolean } = {}
|
||||
): UseAbsolutePositionReturn {
|
||||
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
|
||||
const { useTransform = false } = options
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Rect {
|
||||
@@ -28,26 +28,7 @@ interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
interface UseDomClippingReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updateClipPath: (
|
||||
element: HTMLElement,
|
||||
canvasElement: HTMLCanvasElement,
|
||||
isSelected: boolean,
|
||||
selectedArea?: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
}
|
||||
) => void
|
||||
}
|
||||
|
||||
export function useDomClipping(
|
||||
options: ClippingOptions = {}
|
||||
): UseDomClippingReturn {
|
||||
export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
const style = ref<CSSProperties>({})
|
||||
const { margin = 4 } = options
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const {
|
||||
mockShowNodeOptions,
|
||||
mockUpdateSelectedItems,
|
||||
mockGetCanvasContextMenuTarget
|
||||
} = vi.hoisted(() => ({
|
||||
mockShowNodeOptions: vi.fn(),
|
||||
mockUpdateSelectedItems: vi.fn(),
|
||||
mockGetCanvasContextMenuTarget: vi.fn<
|
||||
() => { reroute?: unknown; group?: unknown }
|
||||
>(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useMoreOptionsMenu', () => ({
|
||||
showNodeOptions: mockShowNodeOptions
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ updateSelectedItems: mockUpdateSelectedItems })
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/canvas/getCanvasContextMenuTarget', () => ({
|
||||
getCanvasContextMenuTarget: mockGetCanvasContextMenuTarget
|
||||
}))
|
||||
|
||||
interface StubCanvas {
|
||||
graph: object
|
||||
deselectAll: ReturnType<typeof vi.fn>
|
||||
selectedItems: Set<unknown>
|
||||
state: { selectionChanged: boolean }
|
||||
}
|
||||
|
||||
describe('useGroupContextMenu', () => {
|
||||
const event = fromPartial<CanvasPointerEvent>({ canvasX: 10, canvasY: 20 })
|
||||
let group: {
|
||||
id: number
|
||||
selected?: boolean
|
||||
recomputeInsideNodes: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let legacyMenuMock: ReturnType<typeof vi.fn>
|
||||
let stubCanvas: StubCanvas
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
LiteGraph.vueNodesMode = true
|
||||
group = { id: 1, recomputeInsideNodes: vi.fn() }
|
||||
mockGetCanvasContextMenuTarget.mockReturnValue({ group })
|
||||
|
||||
legacyMenuMock = vi.fn()
|
||||
LGraphCanvas.prototype.processContextMenu = fromAny(legacyMenuMock)
|
||||
|
||||
useGroupContextMenu()
|
||||
|
||||
stubCanvas = {
|
||||
graph: {},
|
||||
deselectAll: vi.fn(),
|
||||
selectedItems: new Set(),
|
||||
state: { selectionChanged: false }
|
||||
}
|
||||
stubCanvas.deselectAll.mockImplementation(() => {
|
||||
stubCanvas.selectedItems.clear()
|
||||
})
|
||||
})
|
||||
|
||||
function invoke(node: LGraphNode | undefined) {
|
||||
LGraphCanvas.prototype.processContextMenu.call(
|
||||
fromAny(stubCanvas),
|
||||
node,
|
||||
event
|
||||
)
|
||||
}
|
||||
|
||||
it('opens the Vue menu and selects only the group in Nodes 2.0 mode', () => {
|
||||
invoke(undefined)
|
||||
|
||||
expect(stubCanvas.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(group.selected).toBe(true)
|
||||
expect(stubCanvas.selectedItems.has(group)).toBe(true)
|
||||
expect(stubCanvas.state.selectionChanged).toBe(true)
|
||||
expect(group.recomputeInsideNodes).toHaveBeenCalledOnce()
|
||||
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
|
||||
expect(mockUpdateSelectedItems.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockShowNodeOptions.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(legacyMenuMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu when a node is under the cursor', () => {
|
||||
invoke(fromPartial<LGraphNode>({}))
|
||||
|
||||
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu in legacy (non-Nodes 2.0) mode', () => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu when no group is under the cursor', () => {
|
||||
mockGetCanvasContextMenuTarget.mockReturnValue({})
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
expect(stubCanvas.selectedItems.size).toBe(0)
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu when the cursor is on a reroute', () => {
|
||||
mockGetCanvasContextMenuTarget.mockReturnValue({
|
||||
reroute: { id: 5 },
|
||||
group
|
||||
})
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
expect(stubCanvas.selectedItems.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the menu open without re-selecting when only the group is selected', () => {
|
||||
group.selected = true
|
||||
stubCanvas.selectedItems.add(group)
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(stubCanvas.deselectAll).not.toHaveBeenCalled()
|
||||
expect(stubCanvas.selectedItems.size).toBe(1)
|
||||
expect(stubCanvas.selectedItems.has(group)).toBe(true)
|
||||
expect(stubCanvas.state.selectionChanged).toBe(false)
|
||||
expect(group.recomputeInsideNodes).not.toHaveBeenCalled()
|
||||
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
|
||||
expect(legacyMenuMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reselects the group when selected child nodes would hide group actions', () => {
|
||||
const childNode = { selected: true }
|
||||
group.selected = true
|
||||
stubCanvas.selectedItems.add(group)
|
||||
stubCanvas.selectedItems.add(childNode)
|
||||
|
||||
invoke(undefined)
|
||||
|
||||
expect(stubCanvas.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(stubCanvas.selectedItems.size).toBe(1)
|
||||
expect(stubCanvas.selectedItems.has(group)).toBe(true)
|
||||
expect(stubCanvas.state.selectionChanged).toBe(true)
|
||||
expect(group.recomputeInsideNodes).toHaveBeenCalledOnce()
|
||||
expect(mockUpdateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).toHaveBeenCalledWith(event)
|
||||
expect(legacyMenuMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to the legacy menu when the canvas has no graph', () => {
|
||||
LGraphCanvas.prototype.processContextMenu.call(
|
||||
fromAny({ deselectAll: vi.fn() }),
|
||||
undefined,
|
||||
event
|
||||
)
|
||||
|
||||
expect(mockGetCanvasContextMenuTarget).not.toHaveBeenCalled()
|
||||
expect(legacyMenuMock).toHaveBeenCalledOnce()
|
||||
expect(mockShowNodeOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { getCanvasContextMenuTarget } from '@/lib/litegraph/src/canvas/getCanvasContextMenuTarget'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Routes Nodes 2.0 group right-clicks to Vue while nodes, reroutes,
|
||||
* background, and legacy mode stay on litegraph.
|
||||
*/
|
||||
export function useGroupContextMenu() {
|
||||
const original = LGraphCanvas.prototype.processContextMenu
|
||||
|
||||
function processContextMenuWithVueGroupMenu(
|
||||
this: LGraphCanvas,
|
||||
...args: Parameters<typeof original>
|
||||
): void {
|
||||
const [node, event] = args
|
||||
|
||||
if (node || !LiteGraph.vueNodesMode || !this.graph) {
|
||||
original.apply(this, args)
|
||||
return
|
||||
}
|
||||
|
||||
const { reroute, group } = getCanvasContextMenuTarget(
|
||||
this,
|
||||
event.canvasX,
|
||||
event.canvasY
|
||||
)
|
||||
if (reroute || !group) {
|
||||
original.apply(this, args)
|
||||
return
|
||||
}
|
||||
|
||||
const groupIsOnlySelection =
|
||||
this.selectedItems.size === 1 && this.selectedItems.has(group)
|
||||
|
||||
if (!groupIsOnlySelection) {
|
||||
this.deselectAll()
|
||||
group.selected = true
|
||||
group.recomputeInsideNodes()
|
||||
this.selectedItems.add(group)
|
||||
this.state.selectionChanged = true
|
||||
}
|
||||
useCanvasStore().updateSelectedItems()
|
||||
showNodeOptions(event)
|
||||
}
|
||||
|
||||
LGraphCanvas.prototype.processContextMenu = processContextMenuWithVueGroupMenu
|
||||
}
|
||||
@@ -181,24 +181,4 @@ describe('useMaskEditorSaver', () => {
|
||||
expect(store.nodeOutputs[locatorId]).toBeDefined()
|
||||
expect(store.nodeOutputs[locatorId]?.images?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('omits subfolder from the upload FormData under the unified contract', async () => {
|
||||
const fetchApiMock = vi.mocked(api.fetchApi)
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
await save()
|
||||
|
||||
// The unified contract uploads to /upload/image with only image + type;
|
||||
// subfolder is intentionally omitted (the server assigns it). Assert it
|
||||
// here so the next reader knows the omission is deliberate, not accidental.
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
'/upload/image',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
const [, init] = fetchApiMock.mock.calls[0]
|
||||
const body = init?.body as FormData
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect(body.get('type')).toBe('input')
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
@@ -8,6 +6,7 @@ import type {
|
||||
EditorOutputLayer,
|
||||
ImageRef
|
||||
} from '@/stores/maskEditorDataStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
@@ -210,11 +209,18 @@ export function useMaskEditorSaver() {
|
||||
}
|
||||
|
||||
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
|
||||
const actualMaskedRef = await uploadLayer(outputData.maskedImage)
|
||||
const actualPaintRef = await uploadLayer(outputData.paintLayer)
|
||||
const actualPaintedRef = await uploadLayer(outputData.paintedImage)
|
||||
const actualPaintedMaskedRef = await uploadLayer(
|
||||
outputData.paintedMaskedImage
|
||||
const sourceRef = dataStore.inputData!.sourceRef
|
||||
|
||||
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
|
||||
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
|
||||
const actualPaintedRef = await uploadImage(
|
||||
outputData.paintedImage,
|
||||
sourceRef
|
||||
)
|
||||
|
||||
const actualPaintedMaskedRef = await uploadMask(
|
||||
outputData.paintedMaskedImage,
|
||||
actualPaintedRef
|
||||
)
|
||||
|
||||
outputData.maskedImage.ref = actualMaskedRef
|
||||
@@ -223,10 +229,50 @@ export function useMaskEditorSaver() {
|
||||
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
|
||||
}
|
||||
|
||||
async function uploadLayer(layer: EditorOutputLayer): Promise<ImageRef> {
|
||||
async function uploadMask(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/mask', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
}
|
||||
|
||||
return layer.ref
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
layer: EditorOutputLayer,
|
||||
originalRef: ImageRef
|
||||
): Promise<ImageRef> {
|
||||
const formData = new FormData()
|
||||
formData.append('image', layer.blob, layer.ref.filename)
|
||||
formData.append('original_ref', JSON.stringify(originalRef))
|
||||
formData.append('type', 'input')
|
||||
formData.append('subfolder', 'clipspace')
|
||||
|
||||
const response = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -234,35 +280,23 @@ export function useMaskEditorSaver() {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(
|
||||
`Failed to upload ${layer.ref.filename} (${response.status}${body ? `: ${body}` : ''})`
|
||||
)
|
||||
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
|
||||
}
|
||||
|
||||
let data: UploadImageResponse
|
||||
try {
|
||||
data = await response.json()
|
||||
const data = await response.json()
|
||||
if (data?.name) {
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || layer.ref.subfolder,
|
||||
type: data.type || layer.ref.type
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid upload response for ${layer.ref.filename}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error }
|
||||
)
|
||||
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
|
||||
}
|
||||
|
||||
if (!data?.name) {
|
||||
throw new Error(
|
||||
`Upload response missing 'name' for ${layer.ref.filename}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
filename: data.name,
|
||||
subfolder: data.subfolder || '',
|
||||
type: data.type || 'input'
|
||||
}
|
||||
return layer.ref
|
||||
}
|
||||
|
||||
async function updateNodePreview(
|
||||
@@ -288,8 +322,19 @@ export function useMaskEditorSaver() {
|
||||
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
const widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
// Widget value format differs between Cloud and OSS:
|
||||
// - Cloud: JUST the filename (subfolder handled by backend)
|
||||
// - OSS: subfolder/filename (traditional format)
|
||||
let widgetValue: string
|
||||
if (isCloud) {
|
||||
widgetValue =
|
||||
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
} else {
|
||||
widgetValue =
|
||||
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
|
||||
mainRef.filename +
|
||||
(mainRef.type ? ` [${mainRef.type}]` : '')
|
||||
}
|
||||
|
||||
imageWidget.value = widgetValue
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
|
||||
const { mockStartDrag, mockGetNodeProvider } = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockGetNodeProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
|
||||
}))
|
||||
|
||||
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-123',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
size: 1024,
|
||||
created_at: '2025-10-01T00:00:00Z',
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: { filename: 'sd_xl_base_1.0.safetensors' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('startModelNodeDragFromAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('starts a ghost drag for the resolved node carrying the widget value', () => {
|
||||
const nodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
|
||||
|
||||
const error = startModelNodeDragFromAsset(createAsset())
|
||||
|
||||
expect(error).toBeUndefined()
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('threads the node-add source through to the drag', () => {
|
||||
const nodeDef = { name: 'CheckpointLoaderSimple' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
|
||||
|
||||
startModelNodeDragFromAsset(createAsset(), 'asset_browser')
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' },
|
||||
source: 'asset_browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries no widget value when the provider has no key', () => {
|
||||
const nodeDef = { name: 'FL_ChatterboxVC' }
|
||||
mockGetNodeProvider.mockReturnValue({ nodeDef, key: '' })
|
||||
|
||||
startModelNodeDragFromAsset(
|
||||
createAsset({
|
||||
tags: ['models', 'chatterbox/chatterbox_vc'],
|
||||
user_metadata: { filename: 'chatterbox_vc_model.pt' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
|
||||
widgetValues: undefined,
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the resolution error and does not start a drag for an invalid asset', () => {
|
||||
mockGetNodeProvider.mockReturnValue(null)
|
||||
|
||||
const error = startModelNodeDragFromAsset(createAsset())
|
||||
|
||||
expect(error?.code).toBe('NO_PROVIDER')
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
import type { ResolveModelNodeError } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
import type { NodeAddSource } from '@/platform/telemetry/types'
|
||||
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
|
||||
/**
|
||||
* Arms a ghost drag for a model loader node. Providers with no widget key
|
||||
* (auto-load nodes) start the drag without widget values.
|
||||
*/
|
||||
export function startModelLoaderDrag(
|
||||
provider: ModelNodeProvider,
|
||||
filename: string,
|
||||
source: NodeAddSource = 'sidebar_drag'
|
||||
) {
|
||||
const widgetValues = provider.key ? { [provider.key]: filename } : undefined
|
||||
useNodeDragToCanvas().startDrag(provider.nodeDef, { widgetValues, source })
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a ghost drag for the model loader node described by an asset. The
|
||||
* node is created where the user next clicks the canvas, with the asset's
|
||||
* filename written into the loader widget.
|
||||
*
|
||||
* @returns the resolution error when the asset cannot be mapped to a node,
|
||||
* otherwise `undefined`.
|
||||
*/
|
||||
export function startModelNodeDragFromAsset(
|
||||
asset: AssetItem,
|
||||
source: NodeAddSource = 'sidebar_drag'
|
||||
): ResolveModelNodeError | undefined {
|
||||
const resolved = resolveModelNodeFromAsset(asset)
|
||||
if (!resolved.success) return resolved.error
|
||||
|
||||
const { provider, filename } = resolved.value
|
||||
startModelLoaderDrag(provider, filename, source)
|
||||
}
|
||||
@@ -7,8 +7,7 @@ const {
|
||||
mockAddNodeOnGraph,
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockCanvas,
|
||||
mockToastAdd
|
||||
mockCanvas
|
||||
} = vi.hoisted(() => {
|
||||
const mockConvertEventToCanvasOffset = vi.fn()
|
||||
const mockSelectItems = vi.fn()
|
||||
@@ -16,7 +15,6 @@ const {
|
||||
mockAddNodeOnGraph: vi.fn(),
|
||||
mockConvertEventToCanvasOffset,
|
||||
mockSelectItems,
|
||||
mockToastAdd: vi.fn(),
|
||||
mockCanvas: {
|
||||
canvas: {
|
||||
getBoundingClientRect: vi.fn()
|
||||
@@ -39,12 +37,6 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
describe('useNodeDragToCanvas', () => {
|
||||
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
|
||||
|
||||
@@ -62,8 +54,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const { cancelDrag } = useNodeDragToCanvas()
|
||||
cancelDrag()
|
||||
const { cleanupGlobalListeners } = useNodeDragToCanvas()
|
||||
cleanupGlobalListeners()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -79,6 +71,22 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(true)
|
||||
expect(draggedNode.value).toBe(mockNodeDef)
|
||||
})
|
||||
|
||||
it('should set dragMode to click by default', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
|
||||
it('should set dragMode to native when specified', () => {
|
||||
const { dragMode, startDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
expect(dragMode.value).toBe('native')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelDrag', () => {
|
||||
@@ -94,78 +102,75 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(draggedNode.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag listener lifecycle', () => {
|
||||
it('should attach document listeners on startDrag', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
it('should reset dragMode to click', () => {
|
||||
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
startDrag(mockNodeDef, 'native')
|
||||
expect(dragMode.value).toBe('native')
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'keydown',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should not attach drag listeners until a drag starts', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
useNodeDragToCanvas()
|
||||
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
|
||||
'keydown',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should detach document listeners on cancelDrag', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
cancelDrag()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupGlobalListeners', () => {
|
||||
it('should add event listeners to document', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointermove',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
true
|
||||
)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'keydown',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should only attach listeners once across re-arms', () => {
|
||||
it('should only setup listeners once', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
setupGlobalListeners()
|
||||
const callCount = addEventListenerSpy.mock.calls.length
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
setupGlobalListeners()
|
||||
|
||||
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursorPosition', () => {
|
||||
it('should update on pointermove', () => {
|
||||
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
|
||||
const pointerEvent = new PointerEvent('pointermove', {
|
||||
clientX: 100,
|
||||
clientY: 200
|
||||
})
|
||||
document.dispatchEvent(pointerEvent)
|
||||
|
||||
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('endDrag behavior', () => {
|
||||
it('should add node when pointer is over canvas', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
@@ -176,7 +181,9 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
@@ -199,7 +206,10 @@ describe('useNodeDragToCanvas', () => {
|
||||
bottom: 500
|
||||
})
|
||||
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
@@ -214,7 +224,10 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should cancel drag on Escape key', () => {
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(isDragging.value).toBe(true)
|
||||
@@ -226,7 +239,10 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should not cancel drag on other keys', () => {
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
|
||||
@@ -246,7 +262,8 @@ describe('useNodeDragToCanvas', () => {
|
||||
const placedNode = { id: 1 }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
@@ -260,102 +277,6 @@ describe('useNodeDragToCanvas', () => {
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
})
|
||||
|
||||
it('should apply the requested widget values to the placed node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const widget = { name: 'ckpt_name', value: '' }
|
||||
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(widget.value).toBe('model.safetensors')
|
||||
})
|
||||
|
||||
it('should warn but still place the node when a requested widget is missing', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
const placedNode = { id: 1, widgets: [] }
|
||||
mockAddNodeOnGraph.mockReturnValue(placedNode)
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, {
|
||||
widgetValues: { ckpt_name: 'model.safetensors' }
|
||||
})
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
detail: 'assetBrowser.failedToSetModelValue'
|
||||
})
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ckpt_name')
|
||||
)
|
||||
})
|
||||
|
||||
it('should show an error toast when the graph fails to add the node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 500
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
clientY: 250,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'assetBrowser.failedToCreateNode'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should not call selectItems when graph returns no node', () => {
|
||||
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
|
||||
left: 0,
|
||||
@@ -365,9 +286,9 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
mockAddNodeOnGraph.mockReturnValue(null)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
document.dispatchEvent(
|
||||
@@ -390,8 +311,11 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
|
||||
|
||||
const { startDrag, isDragging } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
const { startDrag, setupGlobalListeners, isDragging } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
const pointerEvent = new PointerEvent('pointerup', {
|
||||
clientX: 250,
|
||||
@@ -417,7 +341,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
|
||||
@@ -435,7 +359,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(600, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -453,7 +377,7 @@ describe('useNodeDragToCanvas', () => {
|
||||
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef)
|
||||
startDrag(mockNodeDef, 'click')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -468,12 +392,14 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
|
||||
|
||||
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
|
||||
const { startDrag, handleNativeDrop, isDragging, dragMode } =
|
||||
useNodeDragToCanvas()
|
||||
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
expect(isDragging.value).toBe(false)
|
||||
expect(dragMode.value).toBe('click')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -500,29 +426,31 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should stop propagation when in click-drag mode over canvas', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation once the drag is cancelled', () => {
|
||||
const { startDrag, cancelDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef)
|
||||
cancelDrag()
|
||||
it('should not stop propagation when not dragging', () => {
|
||||
const { setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation in native drag mode', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not stop propagation when pointer is outside canvas', () => {
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef)
|
||||
|
||||
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
|
||||
@@ -549,8 +477,10 @@ describe('useNodeDragToCanvas', () => {
|
||||
}
|
||||
|
||||
it('should prefer tracked drag position over dragend coordinates', () => {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
fireDrag(250, 250)
|
||||
// dragend supplies a bad position (the Firefox bug); the tracked one
|
||||
@@ -564,8 +494,10 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should ignore drag events with (0, 0)', () => {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
fireDrag(250, 250)
|
||||
fireDrag(0, 0)
|
||||
@@ -578,8 +510,10 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
|
||||
it('should fall back to dragend coordinates when no drag fired', () => {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
startDrag(mockNodeDef, 'native')
|
||||
|
||||
handleNativeDrop(250, 250)
|
||||
|
||||
@@ -589,14 +523,32 @@ describe('useNodeDragToCanvas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore dragover events fired before startDrag', () => {
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
fireDrag(250, 250)
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
|
||||
clientX: 300,
|
||||
clientY: 300
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear tracked position between drags', () => {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
const { startDrag, setupGlobalListeners, handleNativeDrop } =
|
||||
useNodeDragToCanvas()
|
||||
setupGlobalListeners()
|
||||
|
||||
startDrag(mockNodeDef, 'native')
|
||||
fireDrag(250, 250)
|
||||
handleNativeDrop(1505, 102)
|
||||
|
||||
// Second drag - no drag events, so we should fall back to args.
|
||||
startDrag(mockNodeDef, { mode: 'native' })
|
||||
startDrag(mockNodeDef, 'native')
|
||||
handleNativeDrop(300, 300)
|
||||
|
||||
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import type { NodeAddSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
type DragMode = 'click' | 'native'
|
||||
type WidgetValues = Record<string, string>
|
||||
type Position = { x: number; y: number }
|
||||
|
||||
interface StartDragOptions {
|
||||
mode?: DragMode
|
||||
widgetValues?: WidgetValues
|
||||
source?: NodeAddSource
|
||||
}
|
||||
|
||||
const isDragging = ref(false)
|
||||
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
|
||||
const cursorPosition = ref({ x: 0, y: 0 })
|
||||
const dragMode = ref<DragMode>('click')
|
||||
const lastNativeDragPosition = shallowRef<Position>()
|
||||
const pendingWidgetValues = shallowRef<WidgetValues>()
|
||||
const pendingSource = ref<NodeAddSource>('sidebar_drag')
|
||||
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
|
||||
let listenersSetup = false
|
||||
|
||||
function updatePosition(e: PointerEvent) {
|
||||
cursorPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
// Firefox dragend can report stale clientX/Y and `drag` can fire with
|
||||
// (0, 0). dragover on the target reliably reports real client coords.
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
|
||||
@@ -36,20 +27,11 @@ function trackNativeDragPosition(e: DragEvent) {
|
||||
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function applyWidgetValues(node: LGraphNode, values: WidgetValues) {
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const widget = node.widgets?.find((w) => w.name === name)
|
||||
if (!widget) {
|
||||
console.error(`Widget ${name} not found on node ${node.type}`)
|
||||
useToastStore().add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('assetBrowser.failedToSetModelValue')
|
||||
})
|
||||
continue
|
||||
}
|
||||
widget.value = value
|
||||
}
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
}
|
||||
|
||||
function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
@@ -77,22 +59,10 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = withNodeAddSource(pendingSource.value, () =>
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
if (!node) {
|
||||
console.error(`Failed to add node to graph: ${nodeDef.name}`)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('assetBrowser.failedToCreateNode')
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (pendingWidgetValues.value)
|
||||
applyWidgetValues(node, pendingWidgetValues.value)
|
||||
canvas.selectItems([node])
|
||||
if (node) canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -122,6 +92,7 @@ function setupGlobalListeners() {
|
||||
if (listenersSetup) return
|
||||
listenersSetup = true
|
||||
|
||||
document.addEventListener('pointermove', updatePosition)
|
||||
document.addEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.addEventListener('pointerup', endDrag, true)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
@@ -132,37 +103,22 @@ function cleanupGlobalListeners() {
|
||||
if (!listenersSetup) return
|
||||
listenersSetup = false
|
||||
|
||||
document.removeEventListener('pointermove', updatePosition)
|
||||
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
|
||||
document.removeEventListener('pointerup', endDrag, true)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('dragover', trackNativeDragPosition)
|
||||
}
|
||||
|
||||
function cancelDrag() {
|
||||
isDragging.value = false
|
||||
draggedNode.value = null
|
||||
dragMode.value = 'click'
|
||||
lastNativeDragPosition.value = undefined
|
||||
pendingWidgetValues.value = undefined
|
||||
pendingSource.value = 'sidebar_drag'
|
||||
cleanupGlobalListeners()
|
||||
if (isDragging.value && dragMode.value === 'click') {
|
||||
cancelDrag()
|
||||
}
|
||||
}
|
||||
|
||||
export function useNodeDragToCanvas() {
|
||||
function startDrag(
|
||||
nodeDef: ComfyNodeDefImpl,
|
||||
{
|
||||
mode = 'click',
|
||||
widgetValues,
|
||||
source = 'sidebar_drag'
|
||||
}: StartDragOptions = {}
|
||||
) {
|
||||
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
|
||||
isDragging.value = true
|
||||
draggedNode.value = nodeDef
|
||||
dragMode.value = mode
|
||||
pendingWidgetValues.value = widgetValues
|
||||
pendingSource.value = source
|
||||
setupGlobalListeners()
|
||||
}
|
||||
|
||||
function handleNativeDrop(clientX: number, clientY: number) {
|
||||
@@ -178,9 +134,12 @@ export function useNodeDragToCanvas() {
|
||||
return {
|
||||
isDragging,
|
||||
draggedNode,
|
||||
pendingWidgetValues,
|
||||
cursorPosition,
|
||||
dragMode,
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
handleNativeDrop
|
||||
handleNativeDrop,
|
||||
setupGlobalListeners,
|
||||
cleanupGlobalListeners
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +124,7 @@ describe('useNodePreviewAndDrag', () => {
|
||||
|
||||
expect(result.isDragging.value).toBe(true)
|
||||
expect(result.isHovered.value).toBe(false)
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
|
||||
mode: 'native'
|
||||
})
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
|
||||
expect(mockDataTransfer.effectAllowed).toBe('copy')
|
||||
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
|
||||
'application/x-comfy-node',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComputedRef, CSSProperties, Ref } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
@@ -8,23 +8,10 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
const PREVIEW_WIDTH = 200
|
||||
const PREVIEW_MARGIN = 16
|
||||
|
||||
interface UseNodePreviewAndDragReturn {
|
||||
previewRef: Ref<HTMLElement | null>
|
||||
isHovered: Ref<boolean>
|
||||
isDragging: Ref<boolean>
|
||||
showPreview: ComputedRef<boolean>
|
||||
nodePreviewStyle: Ref<CSSProperties>
|
||||
sidebarLocation: ComputedRef<'left' | 'right'>
|
||||
handleMouseEnter: (e: MouseEvent) => void
|
||||
handleMouseLeave: () => void
|
||||
handleDragStart: (e: DragEvent) => void
|
||||
handleDragEnd: (e: DragEvent) => void
|
||||
}
|
||||
|
||||
export function useNodePreviewAndDrag(
|
||||
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
|
||||
panelRef?: Ref<HTMLElement | null>
|
||||
): UseNodePreviewAndDragReturn {
|
||||
) {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
@@ -125,7 +112,7 @@ export function useNodePreviewAndDrag(
|
||||
isDragging.value = true
|
||||
isHovered.value = false
|
||||
|
||||
startDrag(nodeDef.value, { mode: 'native' })
|
||||
startDrag(nodeDef.value, 'native')
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
|
||||
@@ -384,63 +384,7 @@ describe('usePainter', () => {
|
||||
'/upload/image',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
expect(result).toBe('uploaded.png [input]')
|
||||
|
||||
const [, init] = fetchApiMock.mock.calls[0]
|
||||
const body = init?.body as FormData
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect(body.get('type')).toBe('input')
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
})
|
||||
|
||||
it('throws when the upload response is missing a name', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => ({})
|
||||
} as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter('test-node', '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/missing 'name'/)
|
||||
})
|
||||
|
||||
it('throws when the upload response body is not valid JSON', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token')
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter('test-node', '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
expect(result).toBe('painter/uploaded.png [temp]')
|
||||
})
|
||||
|
||||
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
@@ -13,6 +12,7 @@ import { hexToRgb } from '@/utils/colorUtil'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -631,7 +631,8 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
const name = `painter-${nodeId}-${Date.now()}.png`
|
||||
const body = new FormData()
|
||||
body.append('image', blob, name)
|
||||
body.append('type', 'input')
|
||||
if (!isCloud) body.append('subfolder', 'painter')
|
||||
body.append('type', isCloud ? 'input' : 'temp')
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
@@ -649,16 +650,15 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
}
|
||||
|
||||
if (resp.status !== 200) {
|
||||
const bodyText = await resp.text().catch(() => '')
|
||||
const err = t('painter.uploadError', {
|
||||
status: resp.status,
|
||||
statusText: bodyText || resp.statusText || 'unknown error'
|
||||
statusText: resp.statusText
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
let data: UploadImageResponse
|
||||
let data: { name: string }
|
||||
try {
|
||||
data = await resp.json()
|
||||
} catch (e) {
|
||||
@@ -670,13 +670,9 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
throw new Error(err, { cause: e })
|
||||
}
|
||||
|
||||
if (!data?.name) {
|
||||
const detail = `Painter upload succeeded (${resp.status}) but response is missing 'name'`
|
||||
toastStore.addAlert(detail)
|
||||
throw new Error(detail)
|
||||
}
|
||||
|
||||
const result = `${data.name} [input]`
|
||||
const result = isCloud
|
||||
? `${data.name} [input]`
|
||||
: `painter/${data.name} [temp]`
|
||||
modelValue.value = result
|
||||
isDirty.value = false
|
||||
return result
|
||||
|
||||
@@ -68,10 +68,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
ComfyWorkflow: class {}
|
||||
}))
|
||||
|
||||
const cancelJobMock = vi.fn()
|
||||
const interruptMock = vi.fn()
|
||||
const deleteItemMock = vi.fn()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
cancelJob: (jobId: string) => cancelJobMock(jobId)
|
||||
interrupt: (runningJobId: string | null) => interruptMock(runningJobId),
|
||||
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -195,7 +197,6 @@ describe('useJobMenu', () => {
|
||||
}))
|
||||
queueStoreMock.update.mockResolvedValue(undefined)
|
||||
queueStoreMock.delete.mockResolvedValue(undefined)
|
||||
cancelJobMock.mockResolvedValue(undefined)
|
||||
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
|
||||
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
|
||||
task,
|
||||
@@ -280,18 +281,29 @@ describe('useJobMenu', () => {
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([['running'], ['initialization'], ['pending']])(
|
||||
'cancels %s job via the state-agnostic jobs-namespace endpoint',
|
||||
async ([state]) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
it.for([
|
||||
['running', interruptMock, deleteItemMock],
|
||||
['initialization', interruptMock, deleteItemMock]
|
||||
])('cancels %s job via interrupt', async ([state]) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
|
||||
await cancelJob()
|
||||
await cancelJob()
|
||||
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
expect(interruptMock).toHaveBeenCalledWith('job-1')
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels pending job via deleteItem', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still updates queue for uncancellable states', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
@@ -299,22 +311,11 @@ describe('useJobMenu', () => {
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(cancelJobMock).not.toHaveBeenCalled()
|
||||
expect(interruptMock).not.toHaveBeenCalled()
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates cancel failures from the API', async () => {
|
||||
cancelJobMock.mockRejectedValueOnce(new Error('Failed to cancel job'))
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'running' }))
|
||||
|
||||
await expect(cancelJob()).rejects.toThrow('Failed to cancel job')
|
||||
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
// Queue refresh is skipped when the cancel request itself fails.
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies error message from failed job entry', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -859,7 +860,7 @@ describe('useJobMenu', () => {
|
||||
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
|
||||
await cancelEntry?.onClick?.()
|
||||
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { st, t } from '@/i18n'
|
||||
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
@@ -82,13 +83,14 @@ export function useJobMenu(
|
||||
const cancelJob = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
if (
|
||||
target.state === 'running' ||
|
||||
target.state === 'initialization' ||
|
||||
target.state === 'pending'
|
||||
) {
|
||||
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
|
||||
await api.cancelJob(target.id)
|
||||
if (target.state === 'running' || target.state === 'initialization') {
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', target.id)
|
||||
} else {
|
||||
await api.interrupt(target.id)
|
||||
}
|
||||
} else if (target.state === 'pending') {
|
||||
await api.deleteItem('queue', target.id)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(target.id)
|
||||
await queueStore.update()
|
||||
|
||||
@@ -9,13 +9,19 @@ export const useQueueClearHistoryDialog = () => {
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
// The content draws its own panel — neutralize the chrome box.
|
||||
contentClass: 'w-fit max-w-90 border-none bg-transparent shadow-none'
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: 'bg-transparent',
|
||||
style: 'padding: 0'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ import { ref } from 'vue'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type * as ModelStoreModule from '@/stores/modelStore'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
// Mock vue-i18n for useExternalLink
|
||||
const mockLocale = ref('en')
|
||||
@@ -137,23 +135,6 @@ vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
|
||||
}))
|
||||
|
||||
const mockAssetBrowse = vi.hoisted(() =>
|
||||
vi.fn<(options: { onAssetSelected?: (asset: AssetItem) => void }) => void>()
|
||||
)
|
||||
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
|
||||
useAssetBrowserDialog: vi.fn(() => ({ browse: mockAssetBrowse }))
|
||||
}))
|
||||
|
||||
const mockStartModelNodeDrag = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/node/startModelNodeDragFromAsset', () => ({
|
||||
startModelNodeDragFromAsset: mockStartModelNodeDrag
|
||||
}))
|
||||
|
||||
const mockChangeTracker = vi.hoisted(() => ({
|
||||
captureCanvasState: vi.fn()
|
||||
}))
|
||||
@@ -637,47 +618,4 @@ describe('useCoreCommands', () => {
|
||||
expect(mockShowAbout).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BrowseModelAssets command', () => {
|
||||
const asset = fromPartial<AssetItem>({ id: 'asset-1' })
|
||||
|
||||
async function selectAssetFromBrowser() {
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
|
||||
|
||||
const command = useCoreCommands().find(
|
||||
(cmd) => cmd.id === 'Comfy.BrowseModelAssets'
|
||||
)!
|
||||
await command.function()
|
||||
|
||||
const { onAssetSelected } = mockAssetBrowse.mock.calls[0][0]
|
||||
onAssetSelected?.(asset)
|
||||
}
|
||||
|
||||
it('starts a model node drag for the selected asset', async () => {
|
||||
mockStartModelNodeDrag.mockReturnValue(undefined)
|
||||
|
||||
await selectAssetFromBrowser()
|
||||
|
||||
expect(mockStartModelNodeDrag).toHaveBeenCalledWith(
|
||||
asset,
|
||||
'asset_browser'
|
||||
)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error toast when the asset cannot start a drag', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockStartModelNodeDrag.mockReturnValue({
|
||||
code: 'NO_PROVIDER',
|
||||
message: 'No node provider registered',
|
||||
assetId: 'asset-1'
|
||||
})
|
||||
|
||||
await selectAssetFromBrowser()
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
|
||||
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -1307,14 +1307,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
assetType: 'models',
|
||||
title: t('sideToolbar.modelLibrary'),
|
||||
onAssetSelected: (asset) => {
|
||||
const error = startModelNodeDragFromAsset(asset, 'asset_browser')
|
||||
if (error) {
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
if (!result.success) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('assetBrowser.failedToCreateNode')
|
||||
})
|
||||
console.error('Node creation failed:', error)
|
||||
console.error('Node creation failed:', result.error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export type ResizeDirection =
|
||||
type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
@@ -17,17 +17,6 @@ export type ResizeDirection =
|
||||
| 'sw'
|
||||
| 'se'
|
||||
|
||||
export interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 8
|
||||
const CORNER_SIZE = 10
|
||||
/** Minimum crop width/height in source image pixel space. */
|
||||
@@ -275,6 +264,17 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
height: `${cropHeight.value * scaleFactor.value}px`
|
||||
}))
|
||||
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
|
||||
|
||||
const allResizeHandles = computed<ResizeHandle[]>(() => {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick } from 'vue'
|
||||
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
|
||||
const { mockActiveWorkflow, statusMap } = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
mockActiveWorkflow: shallowRef<object | null>(null),
|
||||
statusMap: shallowRef<Map<object, WorkflowExecutionStatus>>(new Map())
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
getWorkflowStatus: (workflow: object | null | undefined) =>
|
||||
workflow ? statusMap.value.get(workflow) : undefined,
|
||||
clearWorkflowStatus: (workflow: object) => {
|
||||
const next = new Map(statusMap.value)
|
||||
next.delete(workflow)
|
||||
statusMap.value = next
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
import { useWorkflowStatusDismissal } from './useWorkflowStatusDismissal'
|
||||
|
||||
const workflowA = { path: '/a.json' }
|
||||
const workflowB = { path: '/b.json' }
|
||||
|
||||
function mount() {
|
||||
const scope = effectScope()
|
||||
scope.run(() => useWorkflowStatusDismissal())
|
||||
return () => scope.stop()
|
||||
}
|
||||
|
||||
describe('useWorkflowStatusDismissal', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkflow.value = null
|
||||
statusMap.value = new Map()
|
||||
})
|
||||
|
||||
it('clears a terminal status when its workflow becomes active', async () => {
|
||||
statusMap.value = new Map([[workflowA, 'completed']])
|
||||
const stop = mount()
|
||||
|
||||
mockActiveWorkflow.value = workflowA
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('clears a terminal status that arrives while the workflow is active', async () => {
|
||||
mockActiveWorkflow.value = workflowA
|
||||
const stop = mount()
|
||||
|
||||
statusMap.value = new Map([[workflowA, 'failed']])
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('keeps a running status on the active workflow', async () => {
|
||||
mockActiveWorkflow.value = workflowA
|
||||
const stop = mount()
|
||||
|
||||
statusMap.value = new Map([[workflowA, 'running']])
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.get(workflowA)).toBe('running')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('leaves other workflows untouched', async () => {
|
||||
statusMap.value = new Map([
|
||||
[workflowA, 'completed'],
|
||||
[workflowB, 'completed']
|
||||
])
|
||||
const stop = mount()
|
||||
|
||||
mockActiveWorkflow.value = workflowA
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
expect(statusMap.value.get(workflowB)).toBe('completed')
|
||||
stop()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
export function useWorkflowStatusDismissal() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
|
||||
},
|
||||
([workflow, status]) => {
|
||||
if (workflow && status !== undefined && status !== 'running') {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
@@ -36,15 +36,6 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
options?.afterClose?.()
|
||||
},
|
||||
initialCategory
|
||||
},
|
||||
// The template browser is a wide layout. Without an explicit size the
|
||||
// Reka DialogContent falls back to size 'md' (max-w-xl), clipping the
|
||||
// filter bar so the Clear Filters button lands outside the viewport.
|
||||
// Size it like the other large dialogs (Settings/Manager).
|
||||
dialogComponentProps: {
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[80vh] rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
|
||||
@@ -154,10 +154,8 @@ export const i18n = createI18n({
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const t: (typeof i18n.global)['t'] = i18n.global.t
|
||||
export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
export const { t, te, d } = i18n.global
|
||||
const { tm } = i18n.global
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
|
||||
@@ -27,7 +27,6 @@ import type { LinkId } from './LLink'
|
||||
import { Reroute } from './Reroute'
|
||||
import type { RerouteId } from './Reroute'
|
||||
import { LinkConnector } from './canvas/LinkConnector'
|
||||
import { getCanvasContextMenuTarget } from './canvas/getCanvasContextMenuTarget'
|
||||
import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
|
||||
import { strokeShape } from './draw'
|
||||
import {
|
||||
@@ -8720,25 +8719,42 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
menu_info = this.getCanvasMenuOptions()
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
|
||||
const { reroute, group } = getCanvasContextMenuTarget(
|
||||
this,
|
||||
event.canvasX,
|
||||
event.canvasY
|
||||
)
|
||||
if (reroute) {
|
||||
menu_info.unshift(
|
||||
{
|
||||
content: 'Delete Reroute',
|
||||
callback: () => {
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
// Check for reroutes
|
||||
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
||||
// Try layout store first, fallback to old method
|
||||
const rerouteLayout = layoutStore.queryRerouteAtPoint({
|
||||
x: event.canvasX,
|
||||
y: event.canvasY
|
||||
})
|
||||
|
||||
this.graph.removeReroute(reroute.id)
|
||||
}
|
||||
},
|
||||
null
|
||||
)
|
||||
let reroute: Reroute | undefined
|
||||
if (rerouteLayout) {
|
||||
reroute = this.graph.getReroute(rerouteLayout.id)
|
||||
} else {
|
||||
reroute = this.graph.getRerouteOnPos(
|
||||
event.canvasX,
|
||||
event.canvasY,
|
||||
this._visibleReroutes
|
||||
)
|
||||
}
|
||||
if (reroute) {
|
||||
menu_info.unshift(
|
||||
{
|
||||
content: 'Delete Reroute',
|
||||
callback: () => {
|
||||
if (!this.graph) throw new NullGraphError()
|
||||
|
||||
this.graph.removeReroute(reroute.id)
|
||||
}
|
||||
},
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const group = this.graph.getGroupOnPos(event.canvasX, event.canvasY)
|
||||
if (group) {
|
||||
// on group
|
||||
menu_info.push(null, {
|
||||
content: 'Edit Group',
|
||||
has_submenu: true,
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getCanvasContextMenuTarget } from '@/lib/litegraph/src/canvas/getCanvasContextMenuTarget'
|
||||
import { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
const { mockQueryRerouteAtPoint } = vi.hoisted(() => ({
|
||||
mockQueryRerouteAtPoint: vi.fn<() => unknown>(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: { queryRerouteAtPoint: mockQueryRerouteAtPoint }
|
||||
}))
|
||||
|
||||
interface StubGraph {
|
||||
getReroute: ReturnType<typeof vi.fn>
|
||||
getRerouteOnPos: ReturnType<typeof vi.fn>
|
||||
getGroupOnPos: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface StubCanvas {
|
||||
graph: StubGraph | null
|
||||
links_render_mode: number
|
||||
_visibleReroutes: Set<unknown>
|
||||
}
|
||||
|
||||
describe('getCanvasContextMenuTarget', () => {
|
||||
let graph: StubGraph
|
||||
let canvas: StubCanvas
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryRerouteAtPoint.mockReturnValue(null)
|
||||
graph = {
|
||||
getReroute: vi.fn(() => ({ id: 9 })),
|
||||
getRerouteOnPos: vi.fn(() => undefined),
|
||||
getGroupOnPos: vi.fn(() => ({ id: 1 }))
|
||||
}
|
||||
canvas = {
|
||||
graph,
|
||||
links_render_mode: LinkRenderType.SPLINE_LINK,
|
||||
_visibleReroutes: new Set()
|
||||
}
|
||||
})
|
||||
|
||||
function resolve() {
|
||||
return getCanvasContextMenuTarget(fromAny(canvas), 10, 20)
|
||||
}
|
||||
|
||||
it('returns the group under the point', () => {
|
||||
const target = resolve()
|
||||
|
||||
expect(graph.getGroupOnPos).toHaveBeenCalledWith(10, 20)
|
||||
expect(target.group).toEqual({ id: 1 })
|
||||
expect(target.reroute).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves a reroute from the layout store without the positional fallback', () => {
|
||||
mockQueryRerouteAtPoint.mockReturnValue({ id: 9 })
|
||||
|
||||
const target = resolve()
|
||||
|
||||
expect(graph.getReroute).toHaveBeenCalledWith(9)
|
||||
expect(graph.getRerouteOnPos).not.toHaveBeenCalled()
|
||||
expect(target.reroute).toEqual({ id: 9 })
|
||||
})
|
||||
|
||||
it('falls back to the visible-scoped positional hit-test when the layout store misses', () => {
|
||||
graph.getRerouteOnPos.mockReturnValue({ id: 7 })
|
||||
|
||||
const target = resolve()
|
||||
|
||||
expect(graph.getRerouteOnPos).toHaveBeenCalledWith(
|
||||
10,
|
||||
20,
|
||||
canvas._visibleReroutes
|
||||
)
|
||||
expect(target.reroute).toEqual({ id: 7 })
|
||||
})
|
||||
|
||||
it('skips reroute detection when links are hidden', () => {
|
||||
canvas.links_render_mode = LinkRenderType.HIDDEN_LINK
|
||||
|
||||
const target = resolve()
|
||||
|
||||
expect(mockQueryRerouteAtPoint).not.toHaveBeenCalled()
|
||||
expect(graph.getRerouteOnPos).not.toHaveBeenCalled()
|
||||
expect(target.reroute).toBeUndefined()
|
||||
expect(target.group).toEqual({ id: 1 })
|
||||
})
|
||||
|
||||
it('returns an empty target when the canvas has no graph', () => {
|
||||
canvas.graph = null
|
||||
|
||||
expect(resolve()).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
import type { LGraphCanvas } from '../LGraphCanvas'
|
||||
import type { LGraphGroup } from '../LGraphGroup'
|
||||
import type { Reroute } from '../Reroute'
|
||||
import { LinkRenderType } from '../types/globalEnums'
|
||||
|
||||
interface CanvasContextMenuTarget {
|
||||
reroute?: Reroute
|
||||
group?: LGraphGroup
|
||||
}
|
||||
|
||||
/** Resolves the reroute and group under a canvas-space point for a right-click. */
|
||||
export function getCanvasContextMenuTarget(
|
||||
canvas: LGraphCanvas,
|
||||
x: number,
|
||||
y: number
|
||||
): CanvasContextMenuTarget {
|
||||
const { graph } = canvas
|
||||
if (!graph) return {}
|
||||
|
||||
let reroute: Reroute | undefined
|
||||
if (canvas.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
||||
const layoutHit = layoutStore.queryRerouteAtPoint({ x, y })
|
||||
reroute = layoutHit
|
||||
? graph.getReroute(layoutHit.id)
|
||||
: graph.getRerouteOnPos(
|
||||
x,
|
||||
y,
|
||||
(canvas as unknown as { _visibleReroutes: Iterable<Reroute> })
|
||||
._visibleReroutes
|
||||
)
|
||||
}
|
||||
|
||||
return { reroute, group: graph.getGroupOnPos(x, y) }
|
||||
}
|
||||
@@ -871,7 +871,6 @@
|
||||
"LORA_MODEL": "نموذج لورا",
|
||||
"LOSS_MAP": "خريطة الخسارة",
|
||||
"LUMA_CONCEPTS": "مفاهيم Luma",
|
||||
"LUMA_RAY32_KEYFRAME": "LUMA_RAY32_KEYFRAME",
|
||||
"LUMA_REF": "مرجع Luma",
|
||||
"MASK": "قناع",
|
||||
"MESH": "شبكة",
|
||||
@@ -3980,7 +3979,6 @@
|
||||
"fileTooLarge": "الملف كبير جدًا ({size} ميجابايت). الحد الأقصى المدعوم هو {maxSize} ميجابايت",
|
||||
"fileUploadFailed": "فشل رفع الملف",
|
||||
"interrupted": "تم إيقاف التنفيذ",
|
||||
"invalidTemplateData": "فشل في تحميل قالب العقدة: بيانات غير صالحة",
|
||||
"legacyMaskEditorDeprecated": "محرر القناع القديم لم يعد مدعوماً وسيتم إزالته قريباً.",
|
||||
"migrateToLitegraphReroute": "سيتم إزالة عقد إعادة التوجيه في الإصدارات المستقبلية. انقر للترحيل إلى إعادة التوجيه الأصلية في Litegraph.",
|
||||
"missingModelVerificationFailed": "فشل التحقق من النماذج المفقودة. قد لا تظهر بعض النماذج في علامة تبويب الأخطاء.",
|
||||
|
||||
@@ -2471,10 +2471,6 @@
|
||||
"name": "fuse_method",
|
||||
"tooltip": "الطريقة المستخدمة لدمج نوافذ السياق."
|
||||
},
|
||||
"latent_retain_index_list": {
|
||||
"name": "latent_retain_index_list",
|
||||
"tooltip": "قائمة مؤشرات الـ latent التي سيتم الاحتفاظ بها داخل الضوضاء latent لكل نافذة. تُستخدم في سير العمل حيث يكون المحتوى المرجعي (مثل صورة البداية) موجودًا مباشرةً في الضوضاء latent بدلاً من قنوات التكييف المنفصلة (مثل أسلوب inplace في I2V مثل LTXV، AnimateDiff). مستقلة عن cond_retain_index_list."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "النموذج المراد تطبيق نوافذ السياق عليه أثناء أخذ العينات."
|
||||
@@ -8328,57 +8324,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVContextWindows": {
|
||||
"description": "تعيين نوافذ السياق لنماذج شبيهة بـ LTXV.",
|
||||
"display_name": "نوافذ السياق LTXV",
|
||||
"inputs": {
|
||||
"closed_loop": {
|
||||
"name": "حلقة مغلقة",
|
||||
"tooltip": "ما إذا كان سيتم إغلاق حلقة نافذة السياق؛ تنطبق فقط على الجداول الدائرية."
|
||||
},
|
||||
"context_length": {
|
||||
"name": "طول السياق",
|
||||
"tooltip": "طول نافذة السياق بالإطارات الحقيقية. يجب أن يكون 8*n + 1."
|
||||
},
|
||||
"context_overlap": {
|
||||
"name": "تداخل السياق",
|
||||
"tooltip": "تداخل نافذة السياق بالإطارات الحقيقية."
|
||||
},
|
||||
"context_schedule": {
|
||||
"name": "جدولة السياق",
|
||||
"tooltip": "خوارزمية جدولة تعتمد على الخطوات لنوافذ السياق."
|
||||
},
|
||||
"context_stride": {
|
||||
"name": "خطوة السياق",
|
||||
"tooltip": "خطوة نافذة السياق؛ تنطبق فقط على الجداول الموحدة."
|
||||
},
|
||||
"freenoise": {
|
||||
"name": "freenoise",
|
||||
"tooltip": "ما إذا كان سيتم تطبيق خلط ضوضاء FreeNoise، يحسن دمج النوافذ."
|
||||
},
|
||||
"fuse_method": {
|
||||
"name": "طريقة الدمج",
|
||||
"tooltip": "طريقة دمج نوافذ السياق."
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج الذي سيتم تطبيق نوافذ السياق عليه أثناء التوليد."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "الاحتفاظ بالإطار الأول",
|
||||
"tooltip": "الاحتفاظ بأول إطار latent في كل نافذة سياق (قد يساعد في الحفاظ على المرجع الأولي)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "تقسيم الشروط للنوافذ",
|
||||
"tooltip": "ما إذا كان سيتم تقسيم الشروط المتعددة (التي تم إنشاؤها بواسطة ConditionCombine) لكل نافذة بناءً على مؤشر المنطقة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "النموذج مع تطبيق نوافذ السياق أثناء التوليد."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVCropGuides": {
|
||||
"display_name": "قص أدلة LTXV",
|
||||
"inputs": {
|
||||
@@ -9139,45 +9084,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Load3DAdvanced": {
|
||||
"display_name": "تحميل ثلاثي الأبعاد (متقدم)",
|
||||
"inputs": {
|
||||
"height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"model_file": {
|
||||
"name": "model_file"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "نموذج ثلاثي الأبعاد",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "معلومات النموذج ثلاثي الأبعاد",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "معلومات الكاميرا",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "العرض",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "الارتفاع",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "تحميل الصوت",
|
||||
"inputs": {
|
||||
@@ -9841,262 +9747,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ExtendVideoNode": {
|
||||
"description": "قم بتمديد جيل Ray 3.2 السابق للأمام (استمر بعده) أو للخلف (مقدمة قبله). قم بتوصيل مخرج generation_id من عقدة Luma Ray 3.2 السابقة. الامتدادات دائماً مدتها ٥ ثوانٍ.",
|
||||
"display_name": "Luma Ray 3.2 تمديد الفيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"direction": {
|
||||
"name": "الاتجاه",
|
||||
"tooltip": "الأمام تعني الاستمرار بعد المقطع السابق؛ الخلف تعني الإضافة قبله."
|
||||
},
|
||||
"direction_loop": {
|
||||
"name": "تكرار"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الموجه النصي",
|
||||
"tooltip": "موجه نصي للمحتوى الجديد."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"source_generation_id": {
|
||||
"name": "source_generation_id",
|
||||
"tooltip": "generation_id لفيديو Ray 3.2 السابق الذي تريد تمديده. قم بتوصيل مخرج generation_id من عقدة Luma Ray 3.2 أخرى."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ImageToVideoNode": {
|
||||
"description": "إنشاء فيديو من إطار بداية و/أو نهاية باستخدام نموذج Ray 3.2 من Luma. عمليات التوليد المرتكزة على الصور دائماً مدتها ٥ ثوانٍ.",
|
||||
"display_name": "Luma Ray 3.2 من صورة إلى فيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "إطار النهاية",
|
||||
"tooltip": "الإطار الأخير للفيديو المُنتج."
|
||||
},
|
||||
"loop": {
|
||||
"name": "تكرار",
|
||||
"tooltip": "اجعل الفيديو يتكرر بسلاسة. غير متاح عند تعيين end_frame."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الموجه النصي",
|
||||
"tooltip": "موجه نصي لتوليد الفيديو."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"start_frame": {
|
||||
"name": "إطار البداية",
|
||||
"tooltip": "الإطار الأول للفيديو المُنتج."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframeNode": {
|
||||
"description": "ثبت صورة إرشادية في موضع على خط زمن فيديو Ray 3.2 الناتج. قم بتوصيل هذه العقدة بمدخل 'keyframes' في عقدة Luma Ray 3.2 Keyframes to Video؛ يمكنك ربط عدة عقد معاً عبر مدخل 'keyframes' الاختياري أدناه.",
|
||||
"display_name": "Luma Ray 3.2 إطار رئيسي",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "الصورة",
|
||||
"tooltip": "صورة إرشادية لوضعها في اللحظة المختارة من الفيديو الناتج."
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "الإطارات الرئيسية",
|
||||
"tooltip": "إطارات رئيسية سابقة اختيارية لربطها مع هذه."
|
||||
},
|
||||
"position": {
|
||||
"name": "الموضع",
|
||||
"tooltip": "كيفية وضع هذه الصورة على خط زمن الفيديو الناتج."
|
||||
},
|
||||
"position_fraction": {
|
||||
"name": "النسبة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الإطارات الرئيسية",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframesToVideoNode": {
|
||||
"description": "إنشاء فيديو يقوم بعمل استيفاء بين سلسلة من صور الدليل، كل واحدة منها مرتبطة بموقع على الخط الزمني، باستخدام Luma Ray 3.2. قم ببناء التسلسل باستخدام عقد Luma Ray 3.2 Keyframe (على الأقل ٢).",
|
||||
"display_name": "Luma Ray 3.2 تحويل الإطارات الرئيسية إلى فيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "الإطارات الرئيسية",
|
||||
"tooltip": "تسلسل الإطارات الرئيسية من عقد Luma Ray 3.2 Keyframe (على الأقل ٢)."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الوصف النصي",
|
||||
"tooltip": "الوصف النصي لإنشاء الفيديو."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "معرّف التوليد",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32TextToVideoNode": {
|
||||
"description": "إنشاء فيديو من وصف نصي باستخدام نموذج Luma Ray 3.2.",
|
||||
"display_name": "Luma Ray 3.2 تحويل النص إلى فيديو",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "نسبة العرض إلى الارتفاع"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"duration": {
|
||||
"name": "المدة"
|
||||
},
|
||||
"loop": {
|
||||
"name": "تكرار",
|
||||
"tooltip": "اجعل الفيديو يتكرر بسلاسة. متوفر فقط لمدة ٥ ثوانٍ."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الوصف النصي",
|
||||
"tooltip": "الوصف النصي لإنشاء الفيديو."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "معرّف التوليد",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoEditNode": {
|
||||
"description": "إعادة إنتاج فيديو موجود بناءً على وصف نصي جديد باستخدام Luma Ray 3.2 (تغيير النمط، إعادة الإضاءة، إضافة أو إزالة عناصر) مع الحفاظ على الحركة الأصلية. الفيديو المصدر حتى ١٨ ثانية؛ الفيديو المعدل يحتفظ بطول المصدر.",
|
||||
"display_name": "Luma Ray 3.2 تحرير الفيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد الإنشاء"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الوصف النصي",
|
||||
"tooltip": "يصف التعديل المطلوب."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"strength": {
|
||||
"name": "القوة",
|
||||
"tooltip": "مدى قوة الحفاظ على المصدر مقابل إعادة تخيله. 'تلقائي' يتيح لـ Ray 3.2 الاختيار؛ adhere_* يحافظ على الأصل أكثر، flex_* متوازن، reimagine_* يغير أكثر."
|
||||
},
|
||||
"video": {
|
||||
"name": "الفيديو",
|
||||
"tooltip": "فيديو المصدر للتحرير. حتى ١٨ ثانية."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "معرّف التوليد",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoReframeNode": {
|
||||
"description": "تغيير نسبة العرض إلى الارتفاع لفيديو موجود، باستخدام Luma Ray 3.2 لملء المناطق الجديدة المكشوفة من اللوحة. الفيديو المصدر حتى ٣٠ ثانية. يتم احتساب التكلفة لكل ثانية من الناتج.",
|
||||
"display_name": "إعادة تأطير الفيديو Luma Ray 3.2",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "نسبة العرض إلى الارتفاع"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "موجه",
|
||||
"tooltip": "يصف كيفية ملء المناطق الجديدة المكشوفة من اللوحة."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"video": {
|
||||
"name": "فيديو",
|
||||
"tooltip": "فيديو المصدر لإعادة التأطير. حتى ٣٠ ثانية."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "معرّف التوليد",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaReferenceNode": {
|
||||
"description": "يحتوي على صورة ووزن لاستخدامها مع عقدة توليد صورة لومة.",
|
||||
"display_name": "مرجع لومة",
|
||||
@@ -14425,12 +14075,6 @@
|
||||
"audioUI": {
|
||||
"name": "واجهة المستخدم للصوت"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصوت",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
@@ -14486,11 +14130,6 @@
|
||||
"images": {
|
||||
"name": "الصور"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصور"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
@@ -17286,12 +16925,6 @@
|
||||
"images": {
|
||||
"name": "الصور"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصور",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAnimatedWEBP": {
|
||||
@@ -17315,12 +16948,6 @@
|
||||
"quality": {
|
||||
"name": "الجودة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصور",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudio": {
|
||||
@@ -17335,12 +16962,6 @@
|
||||
"filename_prefix": {
|
||||
"name": "بادئة اسم الملف"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصوت",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
@@ -17362,12 +16983,6 @@
|
||||
"name": "الصيغة",
|
||||
"tooltip": "صيغة الملف التي سيتم حفظ الصوت بها."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصوت",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
@@ -17385,12 +17000,6 @@
|
||||
"quality": {
|
||||
"name": "الجودة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصوت",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioOpus": {
|
||||
@@ -17408,12 +17017,6 @@
|
||||
"quality": {
|
||||
"name": "الجودة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصوت",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveGLB": {
|
||||
@@ -17443,11 +17046,6 @@
|
||||
"name": "الصور",
|
||||
"tooltip": "الصور التي سيتم حفظها."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصور"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageAdvanced": {
|
||||
@@ -17472,12 +17070,6 @@
|
||||
"name": "الصور",
|
||||
"tooltip": "الصور المراد حفظها."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصور",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageDataSetToFolder": {
|
||||
@@ -17545,11 +17137,6 @@
|
||||
"samples": {
|
||||
"name": "عينات"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "عينات"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveLoRA": {
|
||||
@@ -17580,12 +17167,6 @@
|
||||
"svg": {
|
||||
"name": "svg"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "svg",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveTrainingDataset": {
|
||||
@@ -17630,12 +17211,6 @@
|
||||
"name": "الفيديو",
|
||||
"tooltip": "الفيديو الذي سيتم حفظه."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "فيديو",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveWEBM": {
|
||||
@@ -17658,12 +17233,6 @@
|
||||
"name": "الصور",
|
||||
"tooltip": "يتم حفظ صور RGBA مع قناة ألفا الخاصة بها كشفافية (ترميز vp9 فقط)."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "الصور",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScaleROPE": {
|
||||
@@ -22366,14 +21935,6 @@
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "النموذج المراد تطبيق نوافذ السياق عليه أثناء أخذ العينات."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "الاحتفاظ بالإطار الأول",
|
||||
"tooltip": "الاحتفاظ بأول إطار I2V في كل نافذة سياق (قد يساعد في الحفاظ على المرجع الأولي)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "تقسيم الشروط للنوافذ",
|
||||
"tooltip": "ما إذا كان يجب تقسيم الشروط المتعددة (التي تم إنشاؤها بواسطة ConditionCombine) إلى كل نافذة بناءً على مؤشر المنطقة."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -1859,7 +1859,6 @@
|
||||
"LORA_MODEL": "LORA_MODEL",
|
||||
"LOSS_MAP": "LOSS_MAP",
|
||||
"LUMA_CONCEPTS": "LUMA_CONCEPTS",
|
||||
"LUMA_RAY32_KEYFRAME": "LUMA_RAY32_KEYFRAME",
|
||||
"LUMA_REF": "LUMA_REF",
|
||||
"MASK": "MASK",
|
||||
"MESH": "MESH",
|
||||
@@ -2329,8 +2328,7 @@
|
||||
"auth/network-request-failed": "Network error. Please check your connection and try again.",
|
||||
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again.",
|
||||
"generic": "Something went wrong while signing you in. Please try again.",
|
||||
"signupBlocked": "We couldn't create your account right now. Please try again later. If this keeps happening, email support@comfy.org."
|
||||
"generic": "Something went wrong while signing you in. Please try again."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"contactSupport": "To delete your account, please contact {email}"
|
||||
@@ -2420,13 +2418,6 @@
|
||||
"model": "Model",
|
||||
"added": "Added",
|
||||
"accountInitialized": "Account initialized",
|
||||
"eventTypes": {
|
||||
"creditAdded": "Credits Added",
|
||||
"accountCreated": "Account Created",
|
||||
"apiUsage": "API Usage",
|
||||
"gpuUsage": "GPU Usage",
|
||||
"apiNodeUsage": "Partner Node Usage"
|
||||
},
|
||||
"unified": {
|
||||
"message": "Credits have been unified",
|
||||
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
|
||||
@@ -2483,19 +2474,6 @@
|
||||
"confirmCancel": "Cancel subscription",
|
||||
"failed": "Failed to cancel subscription"
|
||||
},
|
||||
"downgrade": {
|
||||
"title": "Change to {plan} plan?",
|
||||
"body": "All other members of this workspace will be immediately removed.",
|
||||
"confirmationPhrase": "I understand",
|
||||
"confirmationPrompt": "Type \"{phrase}\" to confirm.",
|
||||
"confirm": "Change plan",
|
||||
"failed": "Failed to change plan",
|
||||
"notAllowed": "This plan change is not available",
|
||||
"paymentMethodRequired": "A payment method is required to change plans",
|
||||
"paymentPageBlocked": "Couldn't open the payment page — please try again",
|
||||
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
|
||||
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
|
||||
},
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||
"totalCredits": "Total credits",
|
||||
@@ -2538,17 +2516,13 @@
|
||||
"name": "Founder's Edition"
|
||||
},
|
||||
"standard": {
|
||||
"name": "Standard",
|
||||
"feature1": "30 minute max workflow runtime",
|
||||
"feature2": "Add more credits anytime"
|
||||
"name": "Standard"
|
||||
},
|
||||
"creator": {
|
||||
"name": "Creator",
|
||||
"feature1": "Import your own models"
|
||||
"name": "Creator"
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"feature1": "Longer workflow runtime (up to 1 hr)"
|
||||
"name": "Pro"
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
@@ -2576,52 +2550,10 @@
|
||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud and invite members",
|
||||
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
|
||||
"description": "Choose the best plan for you",
|
||||
"descriptionWorkspace": "Choose a Plan",
|
||||
"descriptionWorkspace": "Choose the best plan for your workspace",
|
||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||
"contactUs": "Contact us",
|
||||
"viewEnterprise": "View enterprise",
|
||||
"planScope": {
|
||||
"personal": "For Personal",
|
||||
"team": "For Teams"
|
||||
},
|
||||
"teamHeader": "For teams wanting to collaborate. Need more members? {learnMore} about enterprise.",
|
||||
"teamHeaderLearnMore": "Learn more",
|
||||
"personalHeader": "Personal plans are for individual use only. {action}",
|
||||
"personalHeaderAction": "To add teammates, subscribe to the team plan.",
|
||||
"whatsIncluded": "What's included:",
|
||||
"everythingInPlus": "Everything in {plan}, plus:",
|
||||
"monthlyCredits": "monthly credits",
|
||||
"videoEstimate": "Generates ~{count} 5s videos*",
|
||||
"saveYearly": "Save 20%",
|
||||
"saveYearlyUpTo": "Save up to 20%",
|
||||
"teamPlan": {
|
||||
"name": "Team Plan",
|
||||
"tagline": "Choose your own monthly credit subscription. Get a larger discount with a larger credit subscription.",
|
||||
"detailsTitle": "Details",
|
||||
"perkInviteMembers": "Invite team members",
|
||||
"perkConcurrentRuns": "Members can run workflows concurrently",
|
||||
"perkSharedPool": "Shared credit pool for all members",
|
||||
"perkRolePermissions": "Role-based permissions",
|
||||
"comingSoonLabel": "Coming soon:",
|
||||
"perkProjectAssets": "Project & asset management",
|
||||
"cta": "Subscribe to Team Yearly",
|
||||
"ctaMonthly": "Subscribe to Team Monthly",
|
||||
"changePlan": "Change plan",
|
||||
"currentPlan": "Current plan",
|
||||
"checkoutComingSoon": "Team plan checkout is coming soon."
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
"needMoreMembers": "Need more members?",
|
||||
"flexibility": "Looking for more flexibility or custom features?",
|
||||
"reachOut": "Reach out to us and let's schedule a chat.",
|
||||
"cta": "Learn more"
|
||||
},
|
||||
"pricingBlurb": "*Based on this template, {seeDetails}. Contact us for {questions} or {enterpriseDiscussions}. For more pricing details, {clickHere}.",
|
||||
"pricingBlurbSeeDetails": "see details",
|
||||
"pricingBlurbQuestions": "questions",
|
||||
"pricingBlurbEnterprise": "enterprise discussions",
|
||||
"pricingBlurbClickHere": "click here",
|
||||
"freeTier": {
|
||||
"title": "You're on the Free plan",
|
||||
"description": "Your free plan includes {credits} credits each month to try Comfy Cloud.",
|
||||
@@ -2681,9 +2613,6 @@
|
||||
"starting": "Starting {date}",
|
||||
"ends": "Ends {date}",
|
||||
"eachMonthCreditsRefill": "Each month credits refill to",
|
||||
"everyMonthStarting": "Every month starting {date}",
|
||||
"creditsRefillTo": "Credits refill to",
|
||||
"youllBeCharged": "You'll be charged",
|
||||
"perMember": "/ member",
|
||||
"showMoreFeatures": "Show more features",
|
||||
"hideFeatures": "Hide features",
|
||||
@@ -2696,14 +2625,7 @@
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"addCreditCard": "Add credit card",
|
||||
"confirm": "Confirm",
|
||||
"subscribeToPlan": "Subscribe to {plan}",
|
||||
"switchToPlan": "Switch to {plan}",
|
||||
"backToAllPlans": "Back to all plans"
|
||||
},
|
||||
"success": {
|
||||
"allSet": "You're all set",
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you."
|
||||
}
|
||||
},
|
||||
"userSettings": {
|
||||
@@ -3186,7 +3108,6 @@
|
||||
"invalidFilename": "Invalid Filename",
|
||||
"invalidFilenameDetail": "The asset filename could not be determined. Please try again.",
|
||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||
"failedToSetModelValue": "Node added, but its model could not be set automatically. Check the console for details.",
|
||||
"fileFormats": "File formats",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
@@ -3341,7 +3262,7 @@
|
||||
"error": "Error"
|
||||
},
|
||||
"selection": {
|
||||
"selectedCount": "{count} selected",
|
||||
"selectedCount": "Assets Selected: {count}",
|
||||
"multipleSelectedAssets": "Multiple assets selected",
|
||||
"deselectAll": "Deselect all",
|
||||
"downloadSelected": "Download",
|
||||
@@ -3419,7 +3340,7 @@
|
||||
"mediaLabel": "{count} Media File | {count} Media Files",
|
||||
"modelsLabel": "{count} Model | {count} Models",
|
||||
"checkingAssets": "Checking media visibility…",
|
||||
"acknowledgeCheckbox": "I understand anyone with the link can view these files",
|
||||
"acknowledgeCheckbox": "I understand these media items will be published and made public",
|
||||
"inLibrary": "In library",
|
||||
"comfyHubTitle": "Upload to ComfyHub",
|
||||
"comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",
|
||||
|
||||
@@ -2138,7 +2138,7 @@
|
||||
}
|
||||
},
|
||||
"ComfySwitchNode": {
|
||||
"display_name": "If/Else Switch",
|
||||
"display_name": "Switch",
|
||||
"inputs": {
|
||||
"switch": {
|
||||
"name": "switch"
|
||||
@@ -2445,7 +2445,7 @@
|
||||
},
|
||||
"context_schedule": {
|
||||
"name": "context_schedule",
|
||||
"tooltip": "Step-dependent scheduling algorithm for context windows."
|
||||
"tooltip": "The stride of the context window."
|
||||
},
|
||||
"context_stride": {
|
||||
"name": "context_stride",
|
||||
@@ -2469,16 +2469,12 @@
|
||||
},
|
||||
"cond_retain_index_list": {
|
||||
"name": "cond_retain_index_list",
|
||||
"tooltip": "List of latent indices to retain in the conditioning tensors for each window. For concat-style I2V models (e.g. Wan I2V, HunyuanVideo I2V, Cosmos I2V, SVD) the encoded start image lives in the c_concat conditioning channels; setting this to '0' will retain that start image content at sub-pos 0 of every window."
|
||||
"tooltip": "List of latent indices to retain in the conditioning tensors for each window, for example setting this to '0' will use the initial start image for each window."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "split_conds_to_windows",
|
||||
"tooltip": "Whether to split multiple conditionings (created by ConditionCombine) to each window based on region index."
|
||||
},
|
||||
"latent_retain_index_list": {
|
||||
"name": "latent_retain_index_list",
|
||||
"tooltip": "List of latent indices to retain in the noise latent itself for each window. Use for workflows where reference content (e.g. a start image) lives directly in the noise latent rather than in separate conditioning channels (e.g. inplace-style I2V like LTXV, AnimateDiff). Independent of cond_retain_index_list."
|
||||
},
|
||||
"causal_window_fix": {
|
||||
"name": "causal_window_fix",
|
||||
"tooltip": "Whether to add a causal fix frame to non-0-indexed context windows."
|
||||
@@ -4545,7 +4541,7 @@
|
||||
}
|
||||
},
|
||||
"FrameInterpolate": {
|
||||
"display_name": "Run Frame Interpolation Model",
|
||||
"display_name": "Frame Interpolate",
|
||||
"inputs": {
|
||||
"interp_model": {
|
||||
"name": "interp_model"
|
||||
@@ -8662,45 +8658,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Load3DAdvanced": {
|
||||
"display_name": "Load 3D (Advanced)",
|
||||
"inputs": {
|
||||
"model_file": {
|
||||
"name": "model_file"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "Load Audio",
|
||||
"inputs": {
|
||||
@@ -9307,57 +9264,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVContextWindows": {
|
||||
"display_name": "LTXV Context Windows",
|
||||
"description": "Set context windows for LTXV-like models.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The model to apply context windows to during sampling."
|
||||
},
|
||||
"context_length": {
|
||||
"name": "context_length",
|
||||
"tooltip": "The length of the context window in real frames. Must be 8*n + 1."
|
||||
},
|
||||
"context_overlap": {
|
||||
"name": "context_overlap",
|
||||
"tooltip": "The overlap of the context window in real frames."
|
||||
},
|
||||
"context_schedule": {
|
||||
"name": "context_schedule",
|
||||
"tooltip": "Step-dependent scheduling algorithm for context windows."
|
||||
},
|
||||
"context_stride": {
|
||||
"name": "context_stride",
|
||||
"tooltip": "The stride of the context window; only applicable to uniform schedules."
|
||||
},
|
||||
"closed_loop": {
|
||||
"name": "closed_loop",
|
||||
"tooltip": "Whether to close the context window loop; only applicable to looped schedules."
|
||||
},
|
||||
"fuse_method": {
|
||||
"name": "fuse_method",
|
||||
"tooltip": "The method to use to fuse the context windows."
|
||||
},
|
||||
"freenoise": {
|
||||
"name": "freenoise",
|
||||
"tooltip": "Whether to apply FreeNoise noise shuffling, improves window blending."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "retain_first_frame",
|
||||
"tooltip": "Retain the first latent frame in every context window (may help retain initial reference)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "split_conds_to_windows",
|
||||
"tooltip": "Whether to split multiple conditionings (created by ConditionCombine) to each window based on region index."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "The model with context windows applied during sampling."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVCropGuides": {
|
||||
"display_name": "LTXVCropGuides",
|
||||
"inputs": {
|
||||
@@ -9841,262 +9747,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ExtendVideoNode": {
|
||||
"display_name": "Luma Ray 3.2 Extend Video",
|
||||
"description": "Extend a previous Ray 3.2 generation forward (continue after it) or backward (lead-in before it). Connect the generation_id output of a prior Luma Ray 3.2 node. Extensions are always 5 seconds.",
|
||||
"inputs": {
|
||||
"source_generation_id": {
|
||||
"name": "source_generation_id",
|
||||
"tooltip": "generation_id of the prior Ray 3.2 video to extend. Connect the generation_id output of another Luma Ray 3.2 node."
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"tooltip": "Forward continues after the prior clip; backward is prepended before it."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text prompt for the new content."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; results are nondeterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"direction_loop": {
|
||||
"name": "loop"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ImageToVideoNode": {
|
||||
"display_name": "Luma Ray 3.2 Image to Video",
|
||||
"description": "Generate a video from a start and/or end frame using Luma's Ray 3.2 model. Image-anchored generations are always 5 seconds.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text prompt for the video generation."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"loop": {
|
||||
"name": "loop",
|
||||
"tooltip": "Make the video loop seamlessly. Not available when an end_frame is set."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; results are nondeterministic regardless of seed."
|
||||
},
|
||||
"start_frame": {
|
||||
"name": "start_frame",
|
||||
"tooltip": "First frame of the generated video."
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "end_frame",
|
||||
"tooltip": "Last frame of the generated video."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframeNode": {
|
||||
"display_name": "Luma Ray 3.2 Keyframe",
|
||||
"description": "Anchor a guide image to a position on the Ray 3.2 output video timeline. Connect this to the 'keyframes' input of the Luma Ray 3.2 Keyframes to Video node; chain several together via the optional 'keyframes' input below.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Guide image to place at the chosen moment of the output video."
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"tooltip": "How to place this image on the output video's timeline."
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "keyframes",
|
||||
"tooltip": "Optional earlier keyframes to chain with this one."
|
||||
},
|
||||
"position_fraction": {
|
||||
"name": "fraction"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframesToVideoNode": {
|
||||
"display_name": "Luma Ray 3.2 Keyframes to Video",
|
||||
"description": "Generate a video that interpolates through a sequence of guide images, each anchored to a position on the timeline, using Luma Ray 3.2. Build the sequence with Luma Ray 3.2 Keyframe nodes (at least 2).",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text prompt for the video generation."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; results are nondeterministic regardless of seed."
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "keyframes",
|
||||
"tooltip": "Keyframe sequence from Luma Ray 3.2 Keyframe nodes (at least 2)."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32TextToVideoNode": {
|
||||
"display_name": "Luma Ray 3.2 Text to Video",
|
||||
"description": "Generate a video from a text prompt using Luma's Ray 3.2 model.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text prompt for the video generation."
|
||||
},
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"loop": {
|
||||
"name": "loop",
|
||||
"tooltip": "Make the video loop seamlessly. Only available with 5s duration."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; results are nondeterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoEditNode": {
|
||||
"display_name": "Luma Ray 3.2 Video Edit",
|
||||
"description": "Re-render an existing video under a new prompt using Luma Ray 3.2 (restyle, relight, add or remove elements) while keeping the original motion. Source video up to 18 seconds; the edited video keeps the source's length.",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Source video to edit. Up to 18 seconds."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Describes the desired edit."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength",
|
||||
"tooltip": "How strongly to preserve vs. reimagine the source. 'auto' lets Ray 3.2 choose; adhere_* preserves the most, flex_* is balanced, reimagine_* changes the most."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; results are nondeterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoReframeNode": {
|
||||
"display_name": "Luma Ray 3.2 Video Reframe",
|
||||
"description": "Change the aspect ratio of an existing video, using Luma Ray 3.2 to fill the newly exposed canvas areas. Source video up to 30 seconds. Billed per second of output.",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Source video to reframe. Up to 30 seconds."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Describes how the newly exposed canvas areas should be filled."
|
||||
},
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed to determine if node should re-run; results are nondeterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaReferenceNode": {
|
||||
"display_name": "Luma Reference",
|
||||
"description": "Holds an image and weight for use with Luma Generate Image node.",
|
||||
@@ -14425,12 +14075,6 @@
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
@@ -14486,11 +14130,6 @@
|
||||
"images": {
|
||||
"name": "images"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
@@ -14604,7 +14243,7 @@
|
||||
}
|
||||
},
|
||||
"PrimitiveString": {
|
||||
"display_name": "Text String (DEPRECATED)",
|
||||
"display_name": "Text String",
|
||||
"inputs": {
|
||||
"value": {
|
||||
"name": "value"
|
||||
@@ -14617,7 +14256,7 @@
|
||||
}
|
||||
},
|
||||
"PrimitiveStringMultiline": {
|
||||
"display_name": "Input Text",
|
||||
"display_name": "Text String (Multiline)",
|
||||
"inputs": {
|
||||
"value": {
|
||||
"name": "value"
|
||||
@@ -16990,12 +16629,6 @@
|
||||
"compress_level": {
|
||||
"name": "compress_level"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAnimatedWEBP": {
|
||||
@@ -17019,16 +16652,10 @@
|
||||
"method": {
|
||||
"name": "method"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudio": {
|
||||
"display_name": "Save Audio (FLAC) (DEPRECATED)",
|
||||
"display_name": "Save Audio (FLAC) (Deprecated)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
@@ -17039,12 +16666,6 @@
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
@@ -17066,16 +16687,10 @@
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
"display_name": "Save Audio (MP3) (DEPRECATED)",
|
||||
"display_name": "Save Audio (MP3) (Deprecated)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
@@ -17089,16 +16704,10 @@
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioOpus": {
|
||||
"display_name": "Save Audio (Opus) (DEPRECATED)",
|
||||
"display_name": "Save Audio (Opus) (Deprecated)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
@@ -17112,12 +16721,6 @@
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveGLB": {
|
||||
@@ -17147,11 +16750,6 @@
|
||||
"name": "filename_prefix",
|
||||
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageAdvanced": {
|
||||
@@ -17176,12 +16774,6 @@
|
||||
"format_input_color_space": {
|
||||
"name": "input_color_space"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageDataSetToFolder": {
|
||||
@@ -17249,11 +16841,6 @@
|
||||
"filename_prefix": {
|
||||
"name": "filename_prefix"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "samples"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveLoRA": {
|
||||
@@ -17284,12 +16871,6 @@
|
||||
"name": "filename_prefix",
|
||||
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "svg",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveTrainingDataset": {
|
||||
@@ -17334,12 +16915,6 @@
|
||||
"name": "codec",
|
||||
"tooltip": "The codec to use for the video."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "video",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveWEBM": {
|
||||
@@ -17362,12 +16937,6 @@
|
||||
"name": "crf",
|
||||
"tooltip": "Higher crf means lower quality with a smaller file size, lower crf means higher quality higher filesize."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SCAIL2ColoredMask": {
|
||||
@@ -21110,7 +20679,7 @@
|
||||
}
|
||||
},
|
||||
"Video Slice": {
|
||||
"display_name": "Trim Video",
|
||||
"display_name": "Video Slice",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video"
|
||||
@@ -22371,8 +21940,8 @@
|
||||
}
|
||||
},
|
||||
"WanContextWindowsManual": {
|
||||
"display_name": "Wan Context Windows",
|
||||
"description": "Set context windows for Wan-like models.",
|
||||
"display_name": "WAN Context Windows (Manual)",
|
||||
"description": "Manually set context windows for WAN-like models (dim=2).",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
@@ -22380,15 +21949,15 @@
|
||||
},
|
||||
"context_length": {
|
||||
"name": "context_length",
|
||||
"tooltip": "The length of the context window in real frames. Must be 4*n + 1."
|
||||
"tooltip": "The length of the context window."
|
||||
},
|
||||
"context_overlap": {
|
||||
"name": "context_overlap",
|
||||
"tooltip": "The overlap of the context window in real frames."
|
||||
"tooltip": "The overlap of the context window."
|
||||
},
|
||||
"context_schedule": {
|
||||
"name": "context_schedule",
|
||||
"tooltip": "Step-dependent scheduling algorithm for context windows."
|
||||
"tooltip": "The stride of the context window."
|
||||
},
|
||||
"context_stride": {
|
||||
"name": "context_stride",
|
||||
@@ -22405,14 +21974,6 @@
|
||||
"freenoise": {
|
||||
"name": "freenoise",
|
||||
"tooltip": "Whether to apply FreeNoise noise shuffling, improves window blending."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "retain_first_frame",
|
||||
"tooltip": "Retain the first I2V frame in every context window (may help retain initial reference)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "split_conds_to_windows",
|
||||
"tooltip": "Whether to split multiple conditionings (created by ConditionCombine) to each window based on region index."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -871,7 +871,6 @@
|
||||
"LORA_MODEL": "MODELO_LORA",
|
||||
"LOSS_MAP": "MAPA_PÉRDIDAS",
|
||||
"LUMA_CONCEPTS": "CONCEPTOS LUMA",
|
||||
"LUMA_RAY32_KEYFRAME": "LUMA_RAY32_KEYFRAME",
|
||||
"LUMA_REF": "REFERENCIA LUMA",
|
||||
"MASK": "MASK",
|
||||
"MESH": "MALLA",
|
||||
@@ -3980,7 +3979,6 @@
|
||||
"fileTooLarge": "Archivo demasiado grande ({size} MB). El tamaño máximo soportado es {maxSize} MB",
|
||||
"fileUploadFailed": "Error al subir el archivo",
|
||||
"interrupted": "La ejecución ha sido interrumpida",
|
||||
"invalidTemplateData": "No se pudo cargar la plantilla de nodo: datos no válidos",
|
||||
"legacyMaskEditorDeprecated": "El editor de máscaras heredado está obsoleto y se eliminará pronto.",
|
||||
"migrateToLitegraphReroute": "Los nodos de reroute se eliminarán en futuras versiones. Haz clic para migrar a reroute nativo de litegraph.",
|
||||
"missingModelVerificationFailed": "No se pudo verificar los modelos faltantes. Algunos modelos pueden no aparecer en la pestaña de Errores.",
|
||||
|
||||
@@ -2471,10 +2471,6 @@
|
||||
"name": "método_de_fusión",
|
||||
"tooltip": "El método a utilizar para fusionar las ventanas de contexto."
|
||||
},
|
||||
"latent_retain_index_list": {
|
||||
"name": "latent_retain_index_list",
|
||||
"tooltip": "Lista de índices latentes que se conservarán en el propio espacio latente de ruido para cada ventana. Útil para flujos de trabajo donde el contenido de referencia (por ejemplo, una imagen inicial) reside directamente en el espacio latente de ruido en lugar de en canales de acondicionamiento separados (por ejemplo, I2V estilo inplace como LTXV, AnimateDiff). Independiente de cond_retain_index_list."
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "El modelo al que aplicar ventanas de contexto durante el muestreo."
|
||||
@@ -8328,57 +8324,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVContextWindows": {
|
||||
"description": "Configura ventanas de contexto para modelos tipo LTXV.",
|
||||
"display_name": "Ventanas de Contexto LTXV",
|
||||
"inputs": {
|
||||
"closed_loop": {
|
||||
"name": "closed_loop",
|
||||
"tooltip": "Si se debe cerrar el bucle de la ventana de contexto; solo aplicable a programaciones en bucle."
|
||||
},
|
||||
"context_length": {
|
||||
"name": "context_length",
|
||||
"tooltip": "La longitud de la ventana de contexto en fotogramas reales. Debe ser 8*n + 1."
|
||||
},
|
||||
"context_overlap": {
|
||||
"name": "context_overlap",
|
||||
"tooltip": "La superposición de la ventana de contexto en fotogramas reales."
|
||||
},
|
||||
"context_schedule": {
|
||||
"name": "context_schedule",
|
||||
"tooltip": "Algoritmo de programación dependiente del paso para ventanas de contexto."
|
||||
},
|
||||
"context_stride": {
|
||||
"name": "context_stride",
|
||||
"tooltip": "El intervalo de la ventana de contexto; solo aplicable a programaciones uniformes."
|
||||
},
|
||||
"freenoise": {
|
||||
"name": "freenoise",
|
||||
"tooltip": "Si se debe aplicar el barajado de ruido FreeNoise, mejora la mezcla entre ventanas."
|
||||
},
|
||||
"fuse_method": {
|
||||
"name": "fuse_method",
|
||||
"tooltip": "El método a utilizar para fusionar las ventanas de contexto."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "El modelo al que se aplicarán las ventanas de contexto durante el muestreo."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "retain_first_frame",
|
||||
"tooltip": "Conservar el primer fotograma latente en cada ventana de contexto (puede ayudar a mantener la referencia inicial)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "split_conds_to_windows",
|
||||
"tooltip": "Si se deben dividir múltiples acondicionamientos (creados por ConditionCombine) en cada ventana según el índice de región."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "El modelo con ventanas de contexto aplicadas durante el muestreo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVCropGuides": {
|
||||
"display_name": "LTXVCropGuides",
|
||||
"inputs": {
|
||||
@@ -9139,45 +9084,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Load3DAdvanced": {
|
||||
"display_name": "Cargar 3D (Avanzado)",
|
||||
"inputs": {
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"model_file": {
|
||||
"name": "model_file"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "CargarAudio",
|
||||
"inputs": {
|
||||
@@ -9841,262 +9747,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ExtendVideoNode": {
|
||||
"description": "Extiende una generación previa de Ray 3.2 hacia adelante (continúa después) o hacia atrás (introducción antes). Conecta la salida generation_id de un nodo Luma Ray 3.2 anterior. Las extensiones siempre duran 5 segundos.",
|
||||
"display_name": "Luma Ray 3.2 Extender Video",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"tooltip": "Adelante continúa después del clip anterior; atrás se antepone antes de este."
|
||||
},
|
||||
"direction_loop": {
|
||||
"name": "loop"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt de texto para el nuevo contenido."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"source_generation_id": {
|
||||
"name": "source_generation_id",
|
||||
"tooltip": "generation_id del video Ray 3.2 anterior a extender. Conecta la salida generation_id de otro nodo Luma Ray 3.2."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ImageToVideoNode": {
|
||||
"description": "Genera un video a partir de un fotograma inicial y/o final usando el modelo Ray 3.2 de Luma. Las generaciones ancladas a imagen siempre duran 5 segundos.",
|
||||
"display_name": "Luma Ray 3.2 Imagen a Video",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "end_frame",
|
||||
"tooltip": "Último fotograma del video generado."
|
||||
},
|
||||
"loop": {
|
||||
"name": "loop",
|
||||
"tooltip": "Haz que el video sea un bucle perfecto. No disponible cuando se establece un end_frame."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Prompt de texto para la generación del video."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"start_frame": {
|
||||
"name": "start_frame",
|
||||
"tooltip": "Primer fotograma del video generado."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframeNode": {
|
||||
"description": "Ancla una imagen guía a una posición en la línea de tiempo del video de salida de Ray 3.2. Conéctalo a la entrada 'keyframes' del nodo Luma Ray 3.2 Keyframes to Video; encadena varios juntos mediante la entrada opcional 'keyframes' a continuación.",
|
||||
"display_name": "Luma Ray 3.2 Fotograma Clave",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Imagen guía para colocar en el momento elegido del video de salida."
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "keyframes",
|
||||
"tooltip": "Fotogramas clave anteriores opcionales para encadenar con este."
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"tooltip": "Cómo colocar esta imagen en la línea de tiempo del video de salida."
|
||||
},
|
||||
"position_fraction": {
|
||||
"name": "fraction"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframesToVideoNode": {
|
||||
"description": "Genera un video que interpola a través de una secuencia de imágenes guía, cada una anclada a una posición en la línea de tiempo, usando Luma Ray 3.2. Construye la secuencia con nodos de fotograma clave de Luma Ray 3.2 (al menos 2).",
|
||||
"display_name": "Luma Ray 3.2 Fotogramas clave a video",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "keyframes",
|
||||
"tooltip": "Secuencia de fotogramas clave de los nodos de fotograma clave de Luma Ray 3.2 (al menos 2)."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Texto de entrada para la generación del video."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32TextToVideoNode": {
|
||||
"description": "Genera un video a partir de un texto usando el modelo Ray 3.2 de Luma.",
|
||||
"display_name": "Luma Ray 3.2 Texto a video",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration"
|
||||
},
|
||||
"loop": {
|
||||
"name": "loop",
|
||||
"tooltip": "Hacer que el video se repita sin cortes. Solo disponible con duración de 5s."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Texto de entrada para la generación del video."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoEditNode": {
|
||||
"description": "Vuelve a renderizar un video existente con un nuevo prompt usando Luma Ray 3.2 (restiliza, reilumina, agrega o elimina elementos) manteniendo el movimiento original. Video fuente de hasta 18 segundos; el video editado mantiene la duración del original.",
|
||||
"display_name": "Luma Ray 3.2 Edición de video",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Describe la edición deseada."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength",
|
||||
"tooltip": "Qué tanto se debe preservar o reinventar el video fuente. 'auto' deja que Ray 3.2 decida; adhere_* preserva más, flex_* es equilibrado, reimagine_* cambia más."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Video fuente a editar. Hasta 18 segundos."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoReframeNode": {
|
||||
"description": "Cambia la relación de aspecto de un video existente, usando Luma Ray 3.2 para rellenar las áreas recién expuestas del lienzo. Video de origen de hasta 30 segundos. Facturación por segundo de salida.",
|
||||
"display_name": "Luma Ray 3.2 Reencuadre de Video",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Describe cómo se deben rellenar las áreas recién expuestas del lienzo."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Video de origen para reencuadrar. Hasta 30 segundos."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaReferenceNode": {
|
||||
"description": "Contiene una imagen y un peso para usar con el nodo Luma Generate Image.",
|
||||
"display_name": "Referencia Luma",
|
||||
@@ -14425,12 +14075,6 @@
|
||||
"audioUI": {
|
||||
"name": "interfaz_audio"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
@@ -14486,11 +14130,6 @@
|
||||
"images": {
|
||||
"name": "imagenes"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
@@ -17286,12 +16925,6 @@
|
||||
"images": {
|
||||
"name": "imágenes"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAnimatedWEBP": {
|
||||
@@ -17315,12 +16948,6 @@
|
||||
"quality": {
|
||||
"name": "calidad"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudio": {
|
||||
@@ -17335,12 +16962,6 @@
|
||||
"filename_prefix": {
|
||||
"name": "prefijo_nombre_archivo"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
@@ -17362,12 +16983,6 @@
|
||||
"name": "formato",
|
||||
"tooltip": "El formato de archivo en el que guardar el audio."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
@@ -17385,12 +17000,6 @@
|
||||
"quality": {
|
||||
"name": "calidad"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioOpus": {
|
||||
@@ -17408,12 +17017,6 @@
|
||||
"quality": {
|
||||
"name": "calidad"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveGLB": {
|
||||
@@ -17443,11 +17046,6 @@
|
||||
"name": "imágenes",
|
||||
"tooltip": "Las imágenes a guardar."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageAdvanced": {
|
||||
@@ -17472,12 +17070,6 @@
|
||||
"name": "imágenes",
|
||||
"tooltip": "Las imágenes que se van a guardar."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageDataSetToFolder": {
|
||||
@@ -17545,11 +17137,6 @@
|
||||
"samples": {
|
||||
"name": "muestras"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "samples"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveLoRA": {
|
||||
@@ -17580,12 +17167,6 @@
|
||||
"svg": {
|
||||
"name": "svg"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "svg",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveTrainingDataset": {
|
||||
@@ -17630,12 +17211,6 @@
|
||||
"name": "video",
|
||||
"tooltip": "El video que se va a guardar."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "video",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveWEBM": {
|
||||
@@ -17658,12 +17233,6 @@
|
||||
"name": "imágenes",
|
||||
"tooltip": "Las imágenes RGBA se guardan con su canal alfa como transparencia (solo códec vp9)."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScaleROPE": {
|
||||
@@ -22366,14 +21935,6 @@
|
||||
"model": {
|
||||
"name": "modelo",
|
||||
"tooltip": "El modelo al que aplicar las ventanas de contexto durante el muestreo."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "retain_first_frame",
|
||||
"tooltip": "Conservar el primer fotograma I2V en cada ventana de contexto (puede ayudar a mantener la referencia inicial)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "split_conds_to_windows",
|
||||
"tooltip": "Si se deben dividir múltiples condicionamientos (creados por ConditionCombine) en cada ventana según el índice de región."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -871,7 +871,6 @@
|
||||
"LORA_MODEL": "مدل lora",
|
||||
"LOSS_MAP": "نقشه خطا",
|
||||
"LUMA_CONCEPTS": "مفاهیم Luma",
|
||||
"LUMA_RAY32_KEYFRAME": "LUMA_RAY32_KEYFRAME",
|
||||
"LUMA_REF": "مرجع Luma",
|
||||
"MASK": "ماسک",
|
||||
"MESH": "مش",
|
||||
@@ -3992,7 +3991,6 @@
|
||||
"fileTooLarge": "فایل بیش از حد بزرگ است ({size} مگابایت). حداکثر اندازه مجاز {maxSize} مگابایت است",
|
||||
"fileUploadFailed": "بارگذاری فایل انجام نشد",
|
||||
"interrupted": "اجرا متوقف شد",
|
||||
"invalidTemplateData": "بارگذاری قالب نود ناموفق بود: داده نامعتبر",
|
||||
"legacyMaskEditorDeprecated": "ویرایشگر mask قدیمی منسوخ شده و بهزودی حذف خواهد شد.",
|
||||
"migrateToLitegraphReroute": "nodeهای reroute در نسخههای آینده حذف خواهند شد. برای مهاجرت به reroute بومی litegraph کلیک کنید.",
|
||||
"missingModelVerificationFailed": "تأیید مدلهای مفقود شده انجام نشد. برخی مدلها ممکن است در برگه خطاها نمایش داده نشوند.",
|
||||
|
||||
@@ -2471,10 +2471,6 @@
|
||||
"name": "روش ادغام",
|
||||
"tooltip": "روشی که برای ادغام پنجرههای زمینه استفاده میشود."
|
||||
},
|
||||
"latent_retain_index_list": {
|
||||
"name": "latent_retain_index_list",
|
||||
"tooltip": "فهرست اندیسهای latent که باید در خود latent نویز برای هر پنجره حفظ شوند. برای workflowهایی که محتوای مرجع (مثلاً یک تصویر شروع) مستقیماً در latent نویز قرار دارد و نه در کانالهای شرطیسازی جداگانه (مانند I2V به سبک inplace مثل LTXV، AnimateDiff) استفاده میشود. مستقل از cond_retain_index_list."
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "مدلی که پنجرههای زمینه هنگام نمونهگیری بر آن اعمال میشود."
|
||||
@@ -8328,57 +8324,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVContextWindows": {
|
||||
"description": "تنظیم پنجرههای زمینه برای مدلهای مشابه LTXV.",
|
||||
"display_name": "پنجرههای زمینه LTXV",
|
||||
"inputs": {
|
||||
"closed_loop": {
|
||||
"name": "حلقه بسته",
|
||||
"tooltip": "آیا حلقه پنجره زمینه بسته شود؛ فقط برای زمانبندیهای حلقهای قابل استفاده است."
|
||||
},
|
||||
"context_length": {
|
||||
"name": "طول زمینه",
|
||||
"tooltip": "طول پنجره زمینه بر حسب فریم واقعی. باید به صورت ۸*n + ۱ باشد."
|
||||
},
|
||||
"context_overlap": {
|
||||
"name": "همپوشانی زمینه",
|
||||
"tooltip": "میزان همپوشانی پنجره زمینه بر حسب فریم واقعی."
|
||||
},
|
||||
"context_schedule": {
|
||||
"name": "زمانبندی زمینه",
|
||||
"tooltip": "الگوریتم زمانبندی وابسته به گام برای پنجرههای زمینه."
|
||||
},
|
||||
"context_stride": {
|
||||
"name": "گام زمینه",
|
||||
"tooltip": "گام پنجره زمینه؛ فقط برای زمانبندیهای یکنواخت قابل استفاده است."
|
||||
},
|
||||
"freenoise": {
|
||||
"name": "FreeNoise",
|
||||
"tooltip": "آیا از شافل نویز FreeNoise استفاده شود؛ باعث بهبود ترکیب پنجرهها میشود."
|
||||
},
|
||||
"fuse_method": {
|
||||
"name": "روش ادغام",
|
||||
"tooltip": "روشی که برای ادغام پنجرههای زمینه استفاده میشود."
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "مدلی که پنجرههای زمینه باید هنگام نمونهگیری روی آن اعمال شوند."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "حفظ اولین فریم",
|
||||
"tooltip": "اولین فریم latent را در هر پنجره زمینه حفظ کن (ممکن است به حفظ مرجع اولیه کمک کند)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "تقسیم شرطها به پنجرهها",
|
||||
"tooltip": "آیا شرطهای متعدد (ایجاد شده توسط ConditionCombine) بر اساس اندیس ناحیه به هر پنجره اختصاص داده شوند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "مدل با پنجرههای زمینه اعمالشده هنگام نمونهگیری."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVCropGuides": {
|
||||
"display_name": "LTXVCropGuides",
|
||||
"inputs": {
|
||||
@@ -9139,45 +9084,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Load3DAdvanced": {
|
||||
"display_name": "بارگذاری ۳بعدی (پیشرفته)",
|
||||
"inputs": {
|
||||
"height": {
|
||||
"name": "ارتفاع"
|
||||
},
|
||||
"model_file": {
|
||||
"name": "model_file"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "عرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "مدل ۳بعدی",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "اطلاعات مدل ۳بعدی",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "اطلاعات دوربین",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "عرض",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "ارتفاع",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "بارگذاری صوت",
|
||||
"inputs": {
|
||||
@@ -9841,262 +9747,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ExtendVideoNode": {
|
||||
"description": "یک تولید قبلی Ray 3.2 را به جلو (ادامه پس از آن) یا به عقب (شروع قبل از آن) گسترش دهید. خروجی generation_id یک نود Luma Ray 3.2 قبلی را متصل کنید. هر گسترش همیشه ۵ ثانیه است.",
|
||||
"display_name": "Luma Ray 3.2 گسترش ویدیو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"direction": {
|
||||
"name": "جهت",
|
||||
"tooltip": "جلو به معنای ادامه پس از کلیپ قبلی است؛ عقب به معنای اضافه شدن قبل از آن است."
|
||||
},
|
||||
"direction_loop": {
|
||||
"name": "حلقه"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "پرامپت متنی برای محتوای جدید."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"source_generation_id": {
|
||||
"name": "source_generation_id",
|
||||
"tooltip": "generation_id ویدیوی Ray 3.2 قبلی برای گسترش. خروجی generation_id یک نود دیگر Luma Ray 3.2 را متصل کنید."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ImageToVideoNode": {
|
||||
"description": "تولید ویدیو از یک فریم ابتدایی و/یا انتهایی با استفاده از مدل Ray 3.2 لومـا. تولیدات مبتنی بر تصویر همیشه ۵ ثانیه هستند.",
|
||||
"display_name": "Luma Ray 3.2 تصویر به ویدیو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "فریم انتهایی",
|
||||
"tooltip": "آخرین فریم ویدیوی تولید شده."
|
||||
},
|
||||
"loop": {
|
||||
"name": "حلقه",
|
||||
"tooltip": "ویدیو را به صورت یکپارچه حلقهای کنید. زمانی که end_frame تنظیم شده باشد، در دسترس نیست."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "پرامپت متنی برای تولید ویدیو."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"start_frame": {
|
||||
"name": "فریم ابتدایی",
|
||||
"tooltip": "اولین فریم ویدیوی تولید شده."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframeNode": {
|
||||
"description": "یک تصویر راهنما را در موقعیتی از تایملاین ویدیوی خروجی Ray 3.2 قرار دهید. این node را به ورودی 'keyframes' نود Luma Ray 3.2 Keyframes to Video متصل کنید؛ چندین node را میتوانید از طریق ورودی اختیاری 'keyframes' زیر به هم زنجیر کنید.",
|
||||
"display_name": "Luma Ray 3.2 کیفریم",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "تصویر",
|
||||
"tooltip": "تصویر راهنما برای قرار دادن در لحظه انتخابشده از ویدیوی خروجی."
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "keyframes",
|
||||
"tooltip": "کیفریمهای قبلی اختیاری برای زنجیر شدن با این مورد."
|
||||
},
|
||||
"position": {
|
||||
"name": "موقعیت",
|
||||
"tooltip": "نحوه قرارگیری این تصویر در تایملاین ویدیوی خروجی."
|
||||
},
|
||||
"position_fraction": {
|
||||
"name": "کسری موقعیت"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframesToVideoNode": {
|
||||
"description": "تولید یک ویدیو که از طریق دنبالهای از تصاویر راهنما، هر یک در موقعیتی روی خط زمان قرار دارند، با استفاده از Luma Ray 3.2 در آن میانیابی میشود. این دنباله را با نودهای کلیدفریم Luma Ray 3.2 (حداقل ۲ عدد) بسازید.",
|
||||
"display_name": "Luma Ray 3.2 تبدیل کلیدفریمها به ویدیو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"duration": {
|
||||
"name": "مدت زمان"
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "کلیدفریمها",
|
||||
"tooltip": "دنباله کلیدفریم از نودهای کلیدفریم Luma Ray 3.2 (حداقل ۲ عدد)."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "پرامپت متنی برای تولید ویدیو."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed برای تعیین اینکه آیا node باید دوباره اجرا شود؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "شناسه تولید",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32TextToVideoNode": {
|
||||
"description": "تولید ویدیو از یک پرامپت متنی با استفاده از مدل Ray 3.2 شرکت Luma.",
|
||||
"display_name": "Luma Ray 3.2 متن به ویدیو",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "نسبت تصویر"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"duration": {
|
||||
"name": "مدت زمان"
|
||||
},
|
||||
"loop": {
|
||||
"name": "حلقه",
|
||||
"tooltip": "ویدیو را به صورت بیوقفه تکرارپذیر کنید. فقط برای مدت زمان ۵ ثانیه در دسترس است."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "پرامپت متنی برای تولید ویدیو."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed برای تعیین اینکه آیا node باید دوباره اجرا شود؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "شناسه تولید",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoEditNode": {
|
||||
"description": "بازپرداخت یک ویدیوی موجود با پرامپت جدید با استفاده از Luma Ray 3.2 (تغییر سبک، نورپردازی، افزودن یا حذف عناصر) در حالی که حرکت اصلی حفظ میشود. ویدیوی منبع تا ۱۸ ثانیه؛ ویدیوی ویرایششده طول منبع را حفظ میکند.",
|
||||
"display_name": "Luma Ray 3.2 ویرایش ویدیو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "توضیح ویرایش مورد نظر."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed برای تعیین اینکه آیا node باید دوباره اجرا شود؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"strength": {
|
||||
"name": "شدت",
|
||||
"tooltip": "میزان حفظ یا بازآفرینی ویدیوی منبع. مقدار 'auto' به Ray 3.2 اجازه انتخاب میدهد؛ adhere_* بیشترین حفظ، flex_* متعادل، reimagine_* بیشترین تغییر را اعمال میکند."
|
||||
},
|
||||
"video": {
|
||||
"name": "ویدیو",
|
||||
"tooltip": "ویدیوی منبع برای ویرایش. تا ۱۸ ثانیه."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "شناسه تولید",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoReframeNode": {
|
||||
"description": "نسبت تصویر یک ویدیوی موجود را تغییر دهید و با استفاده از Luma Ray ۳.۲ نواحی جدید بوم را پر کنید. ویدیوی منبع تا ۳۰ ثانیه. هزینه بر اساس هر ثانیه خروجی محاسبه میشود.",
|
||||
"display_name": "تغییر قاب ویدیو با Luma Ray ۳.۲",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "نسبت تصویر"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "توضیح میدهد که نواحی جدید بوم چگونه باید پر شوند."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"video": {
|
||||
"name": "ویدیو",
|
||||
"tooltip": "ویدیوی منبع برای تغییر قاب. تا ۳۰ ثانیه."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaReferenceNode": {
|
||||
"description": "یک تصویر و وزن را برای استفاده در node تولید تصویر لاما نگه میدارد.",
|
||||
"display_name": "مرجع لاما",
|
||||
@@ -14425,12 +14075,6 @@
|
||||
"audioUI": {
|
||||
"name": "رابط کاربری صوت"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "صدا",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
@@ -14486,11 +14130,6 @@
|
||||
"images": {
|
||||
"name": "تصاویر"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تصاویر"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
@@ -17286,12 +16925,6 @@
|
||||
"images": {
|
||||
"name": "images"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تصاویر",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAnimatedWEBP": {
|
||||
@@ -17315,12 +16948,6 @@
|
||||
"quality": {
|
||||
"name": "quality"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تصاویر",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudio": {
|
||||
@@ -17335,12 +16962,6 @@
|
||||
"filename_prefix": {
|
||||
"name": "filename_prefix"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "صدا",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
@@ -17362,12 +16983,6 @@
|
||||
"name": "format",
|
||||
"tooltip": "فرمت فایلی که صدا در آن ذخیره میشود."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "صدا",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
@@ -17385,12 +17000,6 @@
|
||||
"quality": {
|
||||
"name": "quality"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "صدا",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioOpus": {
|
||||
@@ -17408,12 +17017,6 @@
|
||||
"quality": {
|
||||
"name": "quality"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "صدا",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveGLB": {
|
||||
@@ -17443,11 +17046,6 @@
|
||||
"name": "تصاویر",
|
||||
"tooltip": "تصاویری که باید ذخیره شوند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تصاویر"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageAdvanced": {
|
||||
@@ -17472,12 +17070,6 @@
|
||||
"name": "تصاویر",
|
||||
"tooltip": "تصاویری که باید ذخیره شوند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تصاویر",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageDataSetToFolder": {
|
||||
@@ -17545,11 +17137,6 @@
|
||||
"samples": {
|
||||
"name": "نمونهها"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "نمونهها"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveLoRA": {
|
||||
@@ -17580,12 +17167,6 @@
|
||||
"svg": {
|
||||
"name": "SVG"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "SVG",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveTrainingDataset": {
|
||||
@@ -17630,12 +17211,6 @@
|
||||
"name": "ویدیو",
|
||||
"tooltip": "ویدیویی که باید ذخیره شود."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "ویدیو",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveWEBM": {
|
||||
@@ -17658,12 +17233,6 @@
|
||||
"name": "تصاویر",
|
||||
"tooltip": "تصاویر RGBA با کانال آلفا به صورت شفاف ذخیره میشوند (فقط کدک vp9)."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تصاویر",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScaleROPE": {
|
||||
@@ -22366,14 +21935,6 @@
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "مدلی که پنجرههای زمینه روی آن در هنگام نمونهگیری اعمال میشود."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "حفظ اولین فریم",
|
||||
"tooltip": "اولین فریم I2V را در هر پنجره زمینه حفظ کن (ممکن است به حفظ مرجع اولیه کمک کند)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "تقسیم شرایط به پنجرهها",
|
||||
"tooltip": "آیا شرایط متعدد (ایجاد شده توسط ConditionCombine) بر اساس شاخص ناحیه به هر پنجره تقسیم شوند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -871,7 +871,6 @@
|
||||
"LORA_MODEL": "MODÈLE_LORA",
|
||||
"LOSS_MAP": "CARTE_PERTES",
|
||||
"LUMA_CONCEPTS": "Concepts Luma",
|
||||
"LUMA_RAY32_KEYFRAME": "LUMA_RAY32_KEYFRAME",
|
||||
"LUMA_REF": "Référence Luma",
|
||||
"MASK": "MASQUE",
|
||||
"MESH": "MAILLAGE",
|
||||
@@ -3980,7 +3979,6 @@
|
||||
"fileTooLarge": "Fichier trop volumineux ({size} Mo). La taille maximale prise en charge est de {maxSize} Mo",
|
||||
"fileUploadFailed": "Échec du téléchargement du fichier",
|
||||
"interrupted": "L'exécution a été interrompue",
|
||||
"invalidTemplateData": "Échec du chargement du modèle de nœud : données invalides",
|
||||
"legacyMaskEditorDeprecated": "L’éditeur de masque hérité est obsolète et sera bientôt supprimé.",
|
||||
"migrateToLitegraphReroute": "Les nœuds de reroute seront supprimés dans les futures versions. Cliquez pour migrer vers le reroute natif de litegraph.",
|
||||
"missingModelVerificationFailed": "Échec de la vérification des modèles manquants. Certains modèles peuvent ne pas apparaître dans l’onglet Erreurs.",
|
||||
|
||||
@@ -2471,10 +2471,6 @@
|
||||
"name": "méthode_fusion",
|
||||
"tooltip": "La méthode à utiliser pour fusionner les fenêtres de contexte."
|
||||
},
|
||||
"latent_retain_index_list": {
|
||||
"name": "latent_retain_index_list",
|
||||
"tooltip": "Liste des indices latents à conserver dans le latent de bruit lui-même pour chaque fenêtre. À utiliser pour les workflows où le contenu de référence (par exemple, une image de départ) se trouve directement dans le latent de bruit plutôt que dans des canaux de conditionnement séparés (par exemple, I2V de type inplace comme LTXV, AnimateDiff). Indépendant de cond_retain_index_list."
|
||||
},
|
||||
"model": {
|
||||
"name": "modèle",
|
||||
"tooltip": "Le modèle auquel appliquer les fenêtres de contexte pendant l'échantillonnage."
|
||||
@@ -8328,57 +8324,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVContextWindows": {
|
||||
"description": "Définir les fenêtres de contexte pour les modèles de type LTXV.",
|
||||
"display_name": "Fenêtres de contexte LTXV",
|
||||
"inputs": {
|
||||
"closed_loop": {
|
||||
"name": "closed_loop",
|
||||
"tooltip": "Détermine si la boucle de la fenêtre de contexte doit être fermée ; applicable uniquement aux planifications en boucle."
|
||||
},
|
||||
"context_length": {
|
||||
"name": "context_length",
|
||||
"tooltip": "La longueur de la fenêtre de contexte en images réelles. Doit être 8*n + 1."
|
||||
},
|
||||
"context_overlap": {
|
||||
"name": "context_overlap",
|
||||
"tooltip": "Le recouvrement de la fenêtre de contexte en images réelles."
|
||||
},
|
||||
"context_schedule": {
|
||||
"name": "context_schedule",
|
||||
"tooltip": "Algorithme de planification dépendant de l'étape pour les fenêtres de contexte."
|
||||
},
|
||||
"context_stride": {
|
||||
"name": "context_stride",
|
||||
"tooltip": "Le pas de la fenêtre de contexte ; applicable uniquement aux planifications uniformes."
|
||||
},
|
||||
"freenoise": {
|
||||
"name": "freenoise",
|
||||
"tooltip": "Appliquer ou non le mélange de bruit FreeNoise, améliore la fusion des fenêtres."
|
||||
},
|
||||
"fuse_method": {
|
||||
"name": "fuse_method",
|
||||
"tooltip": "La méthode à utiliser pour fusionner les fenêtres de contexte."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "Le modèle auquel appliquer les fenêtres de contexte pendant l'échantillonnage."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "retain_first_frame",
|
||||
"tooltip": "Conserver la première frame latente dans chaque fenêtre de contexte (peut aider à garder la référence initiale)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "split_conds_to_windows",
|
||||
"tooltip": "Séparer ou non les multiples conditionnements (créés par ConditionCombine) dans chaque fenêtre selon l'indice de région."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": "Le modèle avec fenêtres de contexte appliquées pendant l'échantillonnage."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LTXVCropGuides": {
|
||||
"display_name": "LTXVCropGuides",
|
||||
"inputs": {
|
||||
@@ -9139,45 +9084,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Load3DAdvanced": {
|
||||
"display_name": "Charger 3D (Avancé)",
|
||||
"inputs": {
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"model_file": {
|
||||
"name": "model_file"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "ChargerAudio",
|
||||
"inputs": {
|
||||
@@ -9841,262 +9747,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ExtendVideoNode": {
|
||||
"description": "Prolongez une génération Ray 3.2 précédente vers l’avant (continuer après) ou vers l’arrière (ajouter avant). Connectez la sortie generation_id d’un nœud Luma Ray 3.2 précédent. Les extensions durent toujours 5 secondes.",
|
||||
"display_name": "Luma Ray 3.2 Extension Vidéo",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"tooltip": "Avant continue après le clip précédent ; arrière est ajouté avant celui-ci."
|
||||
},
|
||||
"direction_loop": {
|
||||
"name": "boucle"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Invite textuelle pour le nouveau contenu."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats sont non déterministes quel que soit la graine."
|
||||
},
|
||||
"source_generation_id": {
|
||||
"name": "source_generation_id",
|
||||
"tooltip": "generation_id de la vidéo Ray 3.2 précédente à prolonger. Connectez la sortie generation_id d’un autre nœud Luma Ray 3.2."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32ImageToVideoNode": {
|
||||
"description": "Générez une vidéo à partir d’une image de début et/ou de fin en utilisant le modèle Ray 3.2 de Luma. Les générations ancrées sur une image durent toujours 5 secondes.",
|
||||
"display_name": "Luma Ray 3.2 Image vers Vidéo",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"end_frame": {
|
||||
"name": "end_frame",
|
||||
"tooltip": "Dernière image de la vidéo générée."
|
||||
},
|
||||
"loop": {
|
||||
"name": "boucle",
|
||||
"tooltip": "Rendez la vidéo en boucle parfaite. Non disponible si une end_frame est définie."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Invite textuelle pour la génération vidéo."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats sont non déterministes quel que soit la graine."
|
||||
},
|
||||
"start_frame": {
|
||||
"name": "start_frame",
|
||||
"tooltip": "Première image de la vidéo générée."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "generation_id",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframeNode": {
|
||||
"description": "Ancrez une image guide à une position sur la timeline de la vidéo générée par Ray 3.2. Connectez ce nœud à l’entrée 'keyframes' du nœud Luma Ray 3.2 Images Clés vers Vidéo ; enchaînez-en plusieurs via l’entrée optionnelle 'keyframes' ci-dessous.",
|
||||
"display_name": "Luma Ray 3.2 Image Clé",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Image guide à placer au moment choisi sur la vidéo générée."
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "keyframes",
|
||||
"tooltip": "Images clés précédentes optionnelles à enchaîner avec celle-ci."
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"tooltip": "Comment placer cette image sur la timeline de la vidéo générée."
|
||||
},
|
||||
"position_fraction": {
|
||||
"name": "fraction"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32KeyframesToVideoNode": {
|
||||
"description": "Générez une vidéo qui interpole une séquence d’images guides, chacune étant ancrée à une position sur la timeline, en utilisant Luma Ray 3.2. Construisez la séquence avec des nœuds d’images clés Luma Ray 3.2 (au moins 2).",
|
||||
"display_name": "Luma Ray 3.2 : Images clés en vidéo",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"duration": {
|
||||
"name": "durée"
|
||||
},
|
||||
"keyframes": {
|
||||
"name": "images clés",
|
||||
"tooltip": "Séquence d’images clés provenant des nœuds d’images clés Luma Ray 3.2 (au moins 2)."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Invite textuelle pour la génération de la vidéo."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "résolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats restent non déterministes quel que soit le seed."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "identifiant_génération",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32TextToVideoNode": {
|
||||
"description": "Générez une vidéo à partir d’une invite textuelle en utilisant le modèle Ray 3.2 de Luma.",
|
||||
"display_name": "Luma Ray 3.2 : Texte en vidéo",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "ratio d’aspect"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"duration": {
|
||||
"name": "durée"
|
||||
},
|
||||
"loop": {
|
||||
"name": "boucle",
|
||||
"tooltip": "Rend la vidéo en boucle parfaite. Disponible uniquement pour une durée de 5s."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Invite textuelle pour la génération de la vidéo."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "résolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats restent non déterministes quel que soit le seed."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "identifiant_génération",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoEditNode": {
|
||||
"description": "Re-générez une vidéo existante avec une nouvelle invite en utilisant Luma Ray 3.2 (restyliser, rééclairer, ajouter ou supprimer des éléments) tout en conservant le mouvement original. Vidéo source jusqu’à 18 secondes ; la vidéo éditée conserve la durée de la source.",
|
||||
"display_name": "Luma Ray 3.2 : Édition vidéo",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Décrit la modification souhaitée."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "résolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats restent non déterministes quel que soit le seed."
|
||||
},
|
||||
"strength": {
|
||||
"name": "intensité",
|
||||
"tooltip": "Degré de préservation ou de réinvention de la source. 'auto' laisse Ray 3.2 choisir ; adhere_* préserve au maximum, flex_* est équilibré, reimagine_* modifie le plus."
|
||||
},
|
||||
"video": {
|
||||
"name": "vidéo",
|
||||
"tooltip": "Vidéo source à éditer. Jusqu’à 18 secondes."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "identifiant_génération",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaRay32VideoReframeNode": {
|
||||
"description": "Modifiez le format d'image d'une vidéo existante, en utilisant Luma Ray 3.2 pour remplir les nouvelles zones exposées de la toile. Vidéo source jusqu'à 30 secondes. Facturation à la seconde de sortie.",
|
||||
"display_name": "Luma Ray 3.2 Recadrage Vidéo",
|
||||
"inputs": {
|
||||
"aspect_ratio": {
|
||||
"name": "rapport d'aspect"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "invite",
|
||||
"tooltip": "Décrit comment les nouvelles zones exposées de la toile doivent être remplies."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "résolution"
|
||||
},
|
||||
"seed": {
|
||||
"name": "graine",
|
||||
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats sont non déterministes quel que soit la graine."
|
||||
},
|
||||
"video": {
|
||||
"name": "vidéo",
|
||||
"tooltip": "Vidéo source à recadrer. Jusqu'à 30 secondes."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "identifiant_génération",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LumaReferenceNode": {
|
||||
"description": "Contient une image et un poids à utiliser avec le nœud Luma Générer Image.",
|
||||
"display_name": "Référence Luma",
|
||||
@@ -14425,12 +14075,6 @@
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
@@ -14486,11 +14130,6 @@
|
||||
"images": {
|
||||
"name": "images"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
@@ -17286,12 +16925,6 @@
|
||||
"images": {
|
||||
"name": "images"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAnimatedWEBP": {
|
||||
@@ -17315,12 +16948,6 @@
|
||||
"quality": {
|
||||
"name": "qualité"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudio": {
|
||||
@@ -17335,12 +16962,6 @@
|
||||
"filename_prefix": {
|
||||
"name": "préfixe_du_nom_de_fichier"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
@@ -17362,12 +16983,6 @@
|
||||
"name": "format",
|
||||
"tooltip": "Le format de fichier dans lequel enregistrer l’audio."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
@@ -17385,12 +17000,6 @@
|
||||
"quality": {
|
||||
"name": "qualité"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioOpus": {
|
||||
@@ -17408,12 +17017,6 @@
|
||||
"quality": {
|
||||
"name": "qualité"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveGLB": {
|
||||
@@ -17443,11 +17046,6 @@
|
||||
"name": "images",
|
||||
"tooltip": "Les images à enregistrer."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageAdvanced": {
|
||||
@@ -17472,12 +17070,6 @@
|
||||
"name": "images",
|
||||
"tooltip": "Les images à enregistrer."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveImageDataSetToFolder": {
|
||||
@@ -17545,11 +17137,6 @@
|
||||
"samples": {
|
||||
"name": "échantillons"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "échantillons"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveLoRA": {
|
||||
@@ -17580,12 +17167,6 @@
|
||||
"svg": {
|
||||
"name": "svg"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "svg",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveTrainingDataset": {
|
||||
@@ -17630,12 +17211,6 @@
|
||||
"name": "vidéo",
|
||||
"tooltip": "La vidéo à enregistrer."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "vidéo",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveWEBM": {
|
||||
@@ -17658,12 +17233,6 @@
|
||||
"name": "images",
|
||||
"tooltip": "Les images RGBA sont enregistrées avec leur canal alpha comme transparence (codec vp9 uniquement)."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "images",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ScaleROPE": {
|
||||
@@ -22366,14 +21935,6 @@
|
||||
"model": {
|
||||
"name": "modèle",
|
||||
"tooltip": "Le modèle auquel appliquer les fenêtres de contexte pendant l'échantillonnage."
|
||||
},
|
||||
"retain_first_frame": {
|
||||
"name": "conserver_première_image",
|
||||
"tooltip": "Conserver la première image I2V dans chaque fenêtre de contexte (peut aider à garder la référence initiale)."
|
||||
},
|
||||
"split_conds_to_windows": {
|
||||
"name": "diviser_conditions_fenêtres",
|
||||
"tooltip": "Indique s'il faut répartir plusieurs conditionnements (créés par ConditionCombine) dans chaque fenêtre selon l'index de région."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -871,7 +871,6 @@
|
||||
"LORA_MODEL": "LoRAモデル",
|
||||
"LOSS_MAP": "損失マップ",
|
||||
"LUMA_CONCEPTS": "Lumaコンセプト",
|
||||
"LUMA_RAY32_KEYFRAME": "LUMA_RAY32_KEYFRAME",
|
||||
"LUMA_REF": "Luma参照",
|
||||
"MASK": "マスク",
|
||||
"MESH": "メッシュ",
|
||||
@@ -3980,7 +3979,6 @@
|
||||
"fileTooLarge": "ファイルが大きすぎます({size} MB)。サポートされている最大サイズは{maxSize} MBです",
|
||||
"fileUploadFailed": "ファイルのアップロードに失敗しました",
|
||||
"interrupted": "実行が中断されました",
|
||||
"invalidTemplateData": "ノードテンプレートの読み込みに失敗しました:無効なデータです",
|
||||
"legacyMaskEditorDeprecated": "従来のマスクエディタは非推奨となり、まもなく削除されます。",
|
||||
"migrateToLitegraphReroute": "将来のバージョンではRerouteノードが削除されます。litegraph-native rerouteに移行するにはクリックしてください。",
|
||||
"missingModelVerificationFailed": "不足しているモデルの検証に失敗しました。一部のモデルはエラータブに表示されない場合があります。",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user