Compare commits
35 Commits
codex/back
...
v1.45.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b5e0165a | ||
|
|
fe1de3b254 | ||
|
|
1c2ae70343 | ||
|
|
8f68be5699 | ||
|
|
653ef1a4f0 | ||
|
|
c16052e2e3 | ||
|
|
3e94459340 | ||
|
|
ca54877f9d | ||
|
|
a4faaa0159 | ||
|
|
8108967d49 | ||
|
|
0ef98de8eb | ||
|
|
88866fc564 | ||
|
|
1f4a4af079 | ||
|
|
c8c0e53865 | ||
|
|
c8360a092f | ||
|
|
68843967cf | ||
|
|
8c295e7c68 | ||
|
|
219a574eed | ||
|
|
fef2cab31e | ||
|
|
20ee262f78 | ||
|
|
6a8c453659 | ||
|
|
ea277dec4d | ||
|
|
a7aa124c10 | ||
|
|
9c62bbc74a | ||
|
|
f0e16cdf46 | ||
|
|
0658c1ac9c | ||
|
|
997501d8fb | ||
|
|
ab6e5ba094 | ||
|
|
2322a5a497 | ||
|
|
0bc951fd12 | ||
|
|
0446ca7a18 | ||
|
|
653ee48444 | ||
|
|
81d9df61f2 | ||
|
|
f4358cb161 | ||
|
|
5948002dee |
36
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -20,6 +20,8 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -37,31 +39,33 @@ jobs:
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Detect shard coverage data
|
||||
id: coverage-shards
|
||||
run: |
|
||||
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
|
||||
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Install lcov
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: sudo apt-get install -y -qq lcov
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
|
||||
if [ -z "$LCOV_FILES" ]; then
|
||||
echo "No coverage.lcov files found"
|
||||
touch coverage/playwright/coverage.lcov
|
||||
exit 0
|
||||
fi
|
||||
ADD_ARGS=""
|
||||
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
@@ -91,7 +95,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: always()
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
@@ -100,6 +104,7 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
@@ -114,6 +119,7 @@ jobs:
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
@@ -122,7 +128,9 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
33
apps/website/e2e/customers.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customers @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/customers')
|
||||
})
|
||||
|
||||
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
|
||||
await expect(heroImage).toBeVisible()
|
||||
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
|
||||
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
|
||||
|
||||
// Regression guard: an unloaded <img> without intrinsic dimensions
|
||||
// collapses to ~0px, then jumps to its natural size on load and pushes
|
||||
// the video below it. Reserved space must persist before bytes arrive.
|
||||
const heightWhileUnloaded = await page.evaluate(() => {
|
||||
const img = document.querySelector<HTMLImageElement>(
|
||||
'img[alt="Comfy 3D logo"]'
|
||||
)
|
||||
if (!img) return null
|
||||
img.removeAttribute('src')
|
||||
return img.getBoundingClientRect().height
|
||||
})
|
||||
|
||||
expect(heightWhileUnloaded).not.toBeNull()
|
||||
expect(heightWhileUnloaded!).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
|
||||
}
|
||||
|
||||
async function navigateAndSettle(page: Page, url: string) {
|
||||
await page.goto(url)
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('load')
|
||||
}
|
||||
|
||||
test.describe('Home', { tag: '@visual' }, () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 99 KiB |
@@ -28,7 +28,7 @@ export default defineConfig({
|
||||
? [['html'], ['json', { outputFile: 'results.json' }]]
|
||||
: 'html',
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 50 }
|
||||
toHaveScreenshot: { maxDiffPixels: 100 }
|
||||
},
|
||||
...maybeLocalOptions,
|
||||
webServer: {
|
||||
|
||||
@@ -13,7 +13,7 @@ const { stars } = defineProps<{
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="`ComfyUI on GitHub — ${stars} stars`"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
class="hidden shrink-0 items-center gap-1 lg:flex"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="[{ text: stars }]"
|
||||
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
|
||||
size-class="h-5 sm:h-5"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-7"
|
||||
class="bg-primary-comfy-yellow block size-6 shrink-0"
|
||||
aria-hidden="true"
|
||||
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
|
||||
/>
|
||||
|
||||
@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
<!-- Progress bar -->
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
|
||||
class="bg-primary-comfy-yellow h-full rounded-full"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { ScrollTrigger } from '../../scripts/gsapSetup'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -22,6 +23,10 @@ useHeroAnimation({
|
||||
logo: logoRef,
|
||||
video: videoRef
|
||||
})
|
||||
|
||||
function handleLogoLoad() {
|
||||
ScrollTrigger.refresh(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,7 +42,10 @@ useHeroAnimation({
|
||||
<img
|
||||
src="https://media.comfy.org/website/customers/c-projection.webp"
|
||||
alt="Comfy 3D logo"
|
||||
class="mx-auto w-full max-w-md lg:max-w-none"
|
||||
width="1568"
|
||||
height="1763"
|
||||
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
|
||||
@load="handleLogoLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const categories: Category[] = [
|
||||
{
|
||||
label: t('useCase.vfx', locale),
|
||||
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
|
||||
},
|
||||
{
|
||||
label: t('useCase.advertising', locale),
|
||||
|
||||
@@ -190,6 +190,9 @@ export class ComfyPage {
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
|
||||
/** Whether the current test runs in Vue Nodes mode (initialized from `@vue-nodes` tag). */
|
||||
public isVueNodes = false
|
||||
|
||||
/** Test user ID for the current context */
|
||||
get id() {
|
||||
return this.userIds[comfyPageFixture.info().parallelIndex]
|
||||
@@ -500,6 +503,7 @@ export const comfyPageFixture = base.extend<{
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
const isVueNodes = testInfo.tags.includes('@vue-nodes')
|
||||
comfyPage.isVueNodes = isVueNodes
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
|
||||
@@ -217,13 +217,20 @@ export class VueNodeHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for the Enter Subgraph footer button.
|
||||
*/
|
||||
getSubgraphEnterButton(nodeId?: string): Locator {
|
||||
const root = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
return root.getByTestId(TestIds.widgets.subgraphEnterButton).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the subgraph of a node.
|
||||
* @param nodeId - The ID of the node to enter the subgraph of. If not provided, the first matched subgraph will be entered.
|
||||
*/
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
const editButton = this.getSubgraphEnterButton(nodeId)
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ContextMenu {
|
||||
|
||||
async clickMenuItemExact(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,7 +82,7 @@ export class Topbar {
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
return this.page.locator('.p-dialog-content input')
|
||||
return this.page.getByRole('dialog').getByRole('textbox')
|
||||
}
|
||||
|
||||
saveWorkflow(workflowName: string): Promise<void> {
|
||||
@@ -116,9 +116,9 @@ export class Topbar {
|
||||
|
||||
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
|
||||
// If so, return early to let the test handle the confirmation
|
||||
const confirmationDialog = this.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
const confirmationDialog = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Overwrite' })
|
||||
if (await confirmationDialog.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
12
browser_tests/fixtures/components/WidgetSelectDropdown.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
export class WidgetSelectDropdownFixture {
|
||||
public readonly selection: Locator
|
||||
|
||||
constructor(public readonly root: Locator) {
|
||||
this.selection = root.locator('button span span')
|
||||
}
|
||||
async selectedItem(): Promise<string> {
|
||||
return await this.selection.innerText()
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
|
||||
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
|
||||
import { BuilderSelectHelper } from '@e2e/fixtures/helpers/BuilderSelectHelper'
|
||||
import { BuilderStepsHelper } from '@e2e/fixtures/helpers/BuilderStepsHelper'
|
||||
import { MobileAppHelper } from '@e2e/fixtures/helpers/MobileAppHelper'
|
||||
|
||||
export class AppModeHelper {
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly footer: BuilderFooterHelper
|
||||
readonly mobile: MobileAppHelper
|
||||
readonly saveAs: BuilderSaveAsHelper
|
||||
readonly select: BuilderSelectHelper
|
||||
readonly outputHistory: OutputHistoryComponent
|
||||
readonly steps: BuilderStepsHelper
|
||||
readonly widgets: AppModeWidgetHelper
|
||||
|
||||
/** The "Connect an output" popover shown when saving without outputs. */
|
||||
@@ -60,13 +62,16 @@ export class AppModeHelper {
|
||||
public readonly vueNodeSwitchDismissButton: Locator
|
||||
/** The "Don't show again" checkbox inside the Vue Node switch popup. */
|
||||
public readonly vueNodeSwitchDontShowAgainCheckbox: Locator
|
||||
/** The main content area where outputs are displayed*/
|
||||
public readonly centerPanel: Locator
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.mobile = new MobileAppHelper(comfyPage)
|
||||
this.footer = new BuilderFooterHelper(comfyPage)
|
||||
this.saveAs = new BuilderSaveAsHelper(comfyPage)
|
||||
this.select = new BuilderSelectHelper(comfyPage)
|
||||
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
|
||||
this.steps = new BuilderStepsHelper(comfyPage)
|
||||
this.widgets = new AppModeWidgetHelper(comfyPage)
|
||||
|
||||
this.connectOutputPopover = this.page.getByTestId(
|
||||
@@ -125,6 +130,7 @@ export class AppModeHelper {
|
||||
this.vueNodeSwitchDontShowAgainCheckbox = this.page.getByTestId(
|
||||
TestIds.appMode.vueNodeSwitchDontShowAgain
|
||||
)
|
||||
this.centerPanel = this.page.getByTestId(TestIds.linear.centerPanel)
|
||||
}
|
||||
|
||||
private get page(): Page {
|
||||
|
||||
@@ -215,11 +215,12 @@ export class AssetHelper {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const includeTags = parseAssetTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseAssetTagParam(url.searchParams.get('exclude_tags'))
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
let filtered = this.getFilteredAssets(includeTags, excludeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
@@ -296,15 +297,29 @@ export class AssetHelper {
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
private getFilteredAssets(
|
||||
includeTags: string[],
|
||||
excludeTags: string[]
|
||||
): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssetTagParam(value: string | null): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
|
||||
@@ -127,9 +127,7 @@ export class BuilderSelectHelper {
|
||||
await popoverTrigger.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const dialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
const dialogInput = this.page.getByRole('dialog').getByRole('textbox')
|
||||
await dialogInput.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { basename } from 'path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
@@ -13,6 +14,7 @@ export class DragDropHelper {
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
filePath?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
@@ -22,13 +24,14 @@ export class DragDropHelper {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
fileName,
|
||||
filePath,
|
||||
url,
|
||||
waitForUpload = false,
|
||||
preserveNativePropagation = false
|
||||
} = options
|
||||
|
||||
if (!fileName && !url)
|
||||
throw new Error('Must provide either fileName or url')
|
||||
if (!fileName && !filePath && !url)
|
||||
throw new Error('Must provide fileName, filePath, or url')
|
||||
|
||||
const evaluateParams: {
|
||||
dropPosition: Position
|
||||
@@ -39,12 +42,22 @@ export class DragDropHelper {
|
||||
preserveNativePropagation: boolean
|
||||
} = { dropPosition, preserveNativePropagation }
|
||||
|
||||
if (fileName) {
|
||||
const filePath = assetPath(fileName)
|
||||
const buffer = readFileSync(filePath)
|
||||
if (fileName || filePath) {
|
||||
const resolvedPath = filePath ?? assetPath(fileName!)
|
||||
const displayName = fileName ?? basename(resolvedPath)
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = readFileSync(resolvedPath)
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to read drag-and-drop fixture at "${resolvedPath}": ${reason}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getMimeType(fileName)
|
||||
evaluateParams.fileName = displayName
|
||||
evaluateParams.fileType = getMimeType(displayName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
@@ -148,6 +161,13 @@ export class DragDropHelper {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropFilePath(
|
||||
filePath: string,
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ filePath, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
|
||||
33
browser_tests/fixtures/helpers/MobileAppHelper.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class MobileAppHelper {
|
||||
private readonly page: Page
|
||||
readonly contentPanel: Locator
|
||||
readonly navigation: Locator
|
||||
readonly navigationTabs: Locator
|
||||
readonly view: Locator
|
||||
readonly workflows: Locator
|
||||
|
||||
constructor(comfyPage: ComfyPage) {
|
||||
this.page = comfyPage.page
|
||||
this.view = this.page.getByTestId(TestIds.linear.mobile)
|
||||
this.contentPanel = this.page.getByRole('tabpanel')
|
||||
this.navigation = this.page.getByRole('tablist').filter({ hasText: 'Run' })
|
||||
this.navigationTabs = this.navigation.getByRole('tab')
|
||||
this.workflows = this.view.getByTestId(TestIds.linear.mobileWorkflows)
|
||||
}
|
||||
|
||||
async switchWorkflow(workflowName: string) {
|
||||
await this.workflows.click()
|
||||
await this.page.getByRole('menu').getByText(workflowName).click()
|
||||
}
|
||||
async navigateTab(name: 'run' | 'outputs' | 'assets') {
|
||||
await this.navigation.getByRole('tab', { name }).click()
|
||||
}
|
||||
async tap(locator: Locator, { count = 1 }: { count?: number } = {}) {
|
||||
for (let i = 0; i < count; i++) await locator.tap()
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,7 @@ export class NodeOperationsHelper {
|
||||
public readonly promptDialogInput: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
this.promptDialogInput = this.page.locator(
|
||||
'.p-dialog-content input[type="text"]'
|
||||
)
|
||||
this.promptDialogInput = this.page.getByRole('dialog').getByRole('textbox')
|
||||
}
|
||||
|
||||
private get page() {
|
||||
|
||||
@@ -362,6 +362,9 @@ export class SubgraphHelper {
|
||||
|
||||
await this.comfyPage.nextFrame()
|
||||
await expect.poll(async () => this.isInSubgraph()).toBe(false)
|
||||
if (this.comfyPage.isVueNodes) {
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async countGraphPseudoPreviewEntries(): Promise<number> {
|
||||
|
||||
@@ -144,6 +144,14 @@ export const TestIds = {
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button'
|
||||
},
|
||||
linear: {
|
||||
centerPanel: 'linear-center-panel',
|
||||
mobile: 'linear-mobile',
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
footerNav: 'builder-footer-nav',
|
||||
saveButton: 'builder-save-button',
|
||||
|
||||
@@ -7,6 +7,9 @@ export function getMimeType(fileName: string): string {
|
||||
if (name.endsWith('.avif')) return 'image/avif'
|
||||
if (name.endsWith('.webm')) return 'video/webm'
|
||||
if (name.endsWith('.mp4')) return 'video/mp4'
|
||||
if (name.endsWith('.mp3')) return 'audio/mpeg'
|
||||
if (name.endsWith('.flac')) return 'audio/flac'
|
||||
if (name.endsWith('.ogg') || name.endsWith('.opus')) return 'audio/ogg'
|
||||
if (name.endsWith('.json')) return 'application/json'
|
||||
if (name.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function assetPath(fileName: string): string {
|
||||
return `./browser_tests/assets/${fileName}`
|
||||
}
|
||||
|
||||
export function metadataFixturePath(fileName: string): string {
|
||||
return `./src/scripts/metadata/__fixtures__/${fileName}`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export class VueNodeFixture {
|
||||
public readonly collapseButton: Locator
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -23,6 +24,7 @@ export class VueNodeFixture {
|
||||
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
@@ -39,6 +41,16 @@ export class VueNodeFixture {
|
||||
await this.collapseButton.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select this node and delete it via the Delete key, waiting for the node
|
||||
* element to leave the DOM before resolving.
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
await this.header.click()
|
||||
await this.header.press('Delete')
|
||||
await this.locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async getCollapseIconClass(): Promise<string> {
|
||||
return (await this.collapseIcon.getAttribute('class')) ?? ''
|
||||
}
|
||||
|
||||
154
browser_tests/tests/appMode.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
|
||||
|
||||
test.describe('App mode usage', () => {
|
||||
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
const { centerPanel } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(centerPanel, 'Enter app mode').toBeVisible()
|
||||
|
||||
//an app without an image input will load the workflow
|
||||
await test.step('App without an image input loads workflow', async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFile('workflowInMedia/workflow.webp')
|
||||
await expect(centerPanel).toBeHidden()
|
||||
})
|
||||
|
||||
//prep a load image
|
||||
await test.step('Add a load image node', async () => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.page.mouse.dblclick(200, 200, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
|
||||
const loadImage = await comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(loadImage).toBeVisible()
|
||||
})
|
||||
|
||||
const imageInput = new WidgetSelectDropdownFixture(
|
||||
comfyPage.appMode.linearWidgets.locator('.lg-node-widget')
|
||||
)
|
||||
|
||||
await test.step('Enter app mode with image input', async () => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'image']])
|
||||
await expect(centerPanel).toBeVisible()
|
||||
|
||||
await expect(imageInput.root).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Dragging an image redirects to image input', async () => {
|
||||
const initialImage = await imageInput.selectedItem()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropExternalResource({
|
||||
fileName: 'workflow.webp',
|
||||
filePath: './browser_tests/assets/workflowInMedia/workflow.webp',
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
comfyFiles.deleteAfterTest({ filename: 'workflow.webp', type: 'input' })
|
||||
|
||||
await expect(imageInput.selection).not.toHaveText(initialImage)
|
||||
await expect(
|
||||
centerPanel,
|
||||
'A file with workflow should not open a new workflow'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Dragging a url redirects to image input', async () => {
|
||||
const secondImage = await imageInput.selectedItem()
|
||||
await comfyPage.dragDrop.dragAndDropURL('/assets/images/og-image.png', {
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename: 'og-image.png',
|
||||
type: 'input'
|
||||
})
|
||||
await expect(imageInput.selection).not.toHaveText(secondImage)
|
||||
})
|
||||
})
|
||||
|
||||
test('Widget Interaction', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([
|
||||
['3', 'seed'],
|
||||
['3', 'sampler_name'],
|
||||
['6', 'text']
|
||||
])
|
||||
const seed = comfyPage.appMode.linearWidgets.getByLabel('seed', {
|
||||
exact: true
|
||||
})
|
||||
const { input, incrementButton, decrementButton } =
|
||||
comfyPage.vueNodes.getInputNumberControls(seed)
|
||||
const initialValue = Number(await input.inputValue())
|
||||
|
||||
await seed.dragTo(incrementButton, { steps: 5 })
|
||||
const intermediateValue = Number(await input.inputValue())
|
||||
expect(intermediateValue).toBeGreaterThan(initialValue)
|
||||
|
||||
await seed.dragTo(decrementButton, { steps: 5 })
|
||||
const endValue = Number(await input.inputValue())
|
||||
expect(endValue).toBeLessThan(intermediateValue)
|
||||
|
||||
const sampler = comfyPage.appMode.linearWidgets.getByLabel('sampler_name', {
|
||||
exact: true
|
||||
})
|
||||
await sampler.click()
|
||||
|
||||
await comfyPage.page.getByRole('searchbox').fill('uni')
|
||||
await comfyPage.page.keyboard.press('ArrowDown')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(sampler).toHaveText('uni_pc')
|
||||
|
||||
//verify values are consistent with litegraph
|
||||
})
|
||||
|
||||
test.describe('Mobile', { tag: ['@mobile'] }, () => {
|
||||
test('panel navigation', async ({ comfyPage }) => {
|
||||
const { mobile } = comfyPage.appMode
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'steps']])
|
||||
await expect(mobile.view).toBeVisible()
|
||||
await expect(mobile.navigation).toBeVisible()
|
||||
|
||||
await mobile.navigateTab('assets')
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Assets')
|
||||
|
||||
const buttons = await mobile.navigationTabs.all()
|
||||
await buttons[0].dragTo(buttons[2], { steps: 5 })
|
||||
await expect(mobile.contentPanel).toHaveAccessibleName('Outputs')
|
||||
|
||||
await mobile.navigateTab('run')
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeInViewport({ ratio: 1 })
|
||||
|
||||
const steps = comfyPage.page.getByRole('spinbutton')
|
||||
const initialValue = Number(await steps.inputValue())
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'increment' }),
|
||||
{ count: 5 }
|
||||
)
|
||||
await expect(steps).toHaveValue(String(initialValue + 5))
|
||||
await mobile.tap(
|
||||
comfyPage.page.getByRole('button', { name: 'decrement' }),
|
||||
{ count: 3 }
|
||||
)
|
||||
|
||||
await expect(steps).toHaveValue(String(initialValue + 2))
|
||||
})
|
||||
|
||||
test('workflow selection', async ({ comfyPage }) => {
|
||||
const widgetNames = ['seed', 'steps', 'denoise', 'cfg']
|
||||
for (const name of widgetNames)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', name]])
|
||||
await expect(comfyPage.appMode.mobile.workflows).toBeVisible()
|
||||
|
||||
const widgets = comfyPage.appMode.linearWidgets
|
||||
await comfyPage.appMode.mobile.navigateTab('run')
|
||||
for (let i = 0; i < widgetNames.length; i++) {
|
||||
await comfyPage.appMode.mobile.switchWorkflow(`(${i + 2})`)
|
||||
await expect(widgets.getByText(widgetNames[i])).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
121
browser_tests/tests/appModeBuilder.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('App mode builder selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enableLinearMode()
|
||||
})
|
||||
|
||||
test('Can independently select inputs of same name', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
|
||||
await comfyPage.vueNodes.selectNodes(['6', '7'])
|
||||
await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
const prompts = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.locator('.lg-node-widget')
|
||||
const count = await prompts.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(prompts.nth(i)).toBeVisible()
|
||||
await prompts.nth(i).click()
|
||||
await expect(items).toHaveCount(i + 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('Can select outputs', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToOutputs()
|
||||
|
||||
await comfyPage.nodeOps
|
||||
.getNodeRefById('9')
|
||||
.then((ref) => ref.centerOnNode())
|
||||
const saveImage = await comfyPage.vueNodes.getNodeLocator('9')
|
||||
await saveImage.click()
|
||||
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
await expect(items).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can not select nodes with errors or notes', async ({ comfyPage }) => {
|
||||
//Manually set error state on checkpoint loader
|
||||
//Shouldn't be needed on ci, but has spotty reliability
|
||||
await comfyPage.page.evaluate(() => (graph!.nodes[6].has_errors = true))
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const items = comfyPage.appMode.select.inputItems
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.appMode.select.selectInputWidget(
|
||||
'Load Checkpoint',
|
||||
'ckpt_name'
|
||||
)
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
await expect(items).toHaveCount(0)
|
||||
|
||||
await comfyPage.appMode.select.selectInputWidget('Note', 'text')
|
||||
await comfyPage.appMode.select.selectInputWidget('Markdown Note', 'text')
|
||||
|
||||
await expect(items).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Marks canvas readOnly', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is initially editable'
|
||||
).toHaveCount(1)
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.appMode.enterBuilder()
|
||||
await comfyPage.appMode.steps.goToInputs()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Entering builder makes the canvas readonly'
|
||||
).toHaveCount(0)
|
||||
|
||||
await comfyPage.page.keyboard.press('Space')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas remains readonly after pressing space'
|
||||
).toHaveCount(0)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await ksampler.header.dblclick({ force: true })
|
||||
await expect(
|
||||
ksampler.titleEditor.input,
|
||||
'Double clicking node titles will not initiate a rename'
|
||||
).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.page.mouse.dblclick(100, 100, { delay: 5 })
|
||||
await expect(
|
||||
comfyPage.searchBox.input,
|
||||
'Canvas is no longer readonly after exiting'
|
||||
).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
@@ -133,6 +133,29 @@ test.describe('AssetHelper', () => {
|
||||
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
|
||||
})
|
||||
|
||||
test('GET /assets filters by exclude_tags', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset({
|
||||
...STABLE_INPUT_IMAGE,
|
||||
id: 'missing-input',
|
||||
tags: ['input', 'missing']
|
||||
})
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=input,&exclude_tags= missing,`
|
||||
)
|
||||
const data = body as { assets: Array<{ id: string }> }
|
||||
expect(data.assets.map((asset) => asset.id)).toEqual([
|
||||
STABLE_INPUT_IMAGE.id
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /assets/:id returns single asset or 404', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
|
||||
@@ -229,9 +229,9 @@ test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
|
||||
// The dialog appearing proves the keybinding was intercepted by the app.
|
||||
await comfyPage.keyboard.press('Control+s')
|
||||
|
||||
// The Save As dialog should appear (p-dialog overlay)
|
||||
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
|
||||
await expect(dialogOverlay).toBeVisible()
|
||||
// The Save As dialog should appear
|
||||
const saveDialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(saveDialog).toBeVisible()
|
||||
|
||||
// Dismiss the dialog
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
@@ -16,9 +16,9 @@ async function saveAndOpenPublishDialog(
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
const overwriteDialog = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Overwrite' })
|
||||
// Bounded wait: point-in-time isVisible() can miss dialogs that open
|
||||
// slightly after saveWorkflow() resolves.
|
||||
try {
|
||||
|
||||
62
browser_tests/tests/metadataWorkflowImport.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { metadataFixturePath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
type MetadataFixture = {
|
||||
fileName: string
|
||||
parser: string
|
||||
}
|
||||
|
||||
// Each fixture embeds the same single-KSampler workflow (see
|
||||
// scripts/generate-embedded-metadata-test-files.py), exercising a different
|
||||
// parser in src/scripts/metadata/. Dropping the file should import that
|
||||
// workflow.
|
||||
const FIXTURES: readonly MetadataFixture[] = [
|
||||
{ fileName: 'with_metadata.png', parser: 'png' },
|
||||
{ fileName: 'with_metadata.avif', parser: 'avif' },
|
||||
{ fileName: 'with_metadata.webp', parser: 'webp' },
|
||||
{ fileName: 'with_metadata_exif_prefix.webp', parser: 'webp (exif prefix)' },
|
||||
{ fileName: 'with_metadata.flac', parser: 'flac' },
|
||||
{ fileName: 'with_metadata.mp3', parser: 'mp3' },
|
||||
{ fileName: 'with_metadata.opus', parser: 'ogg' },
|
||||
{ fileName: 'with_metadata.mp4', parser: 'isobmff' },
|
||||
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
|
||||
] as const
|
||||
|
||||
test.describe(
|
||||
'Metadata drop-to-load workflow import',
|
||||
{ tag: ['@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
})
|
||||
|
||||
for (const { fileName, parser } of FIXTURES) {
|
||||
test(`loads embedded workflow from ${fileName} (${parser})`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await test.step(`drop ${fileName} on canvas`, async () => {
|
||||
await comfyPage.dragDrop.dragAndDropFilePath(
|
||||
metadataFixturePath(fileName)
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('graph contains only the embedded KSampler', async () => {
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const ksamplers =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
expect(
|
||||
ksamplers,
|
||||
'exactly one KSampler should have been loaded from the fixture'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -692,19 +692,27 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Controls collapse to single column in compact mode', async ({
|
||||
test('Controls stack label above widget in compact mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
||||
const brushButton = painterWidget.getByText('Brush', { exact: true })
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should be visible in two-column layout'
|
||||
'tool label should be visible in wide layout'
|
||||
).toBeVisible()
|
||||
|
||||
const wideLabelBox = await toolLabel.boundingBox()
|
||||
const wideBrushBox = await brushButton.boundingBox()
|
||||
expect(
|
||||
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
|
||||
'label should sit to the left of the brush button in wide layout'
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
@@ -716,8 +724,22 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
|
||||
await expect(
|
||||
toolLabel,
|
||||
'tool label should hide in compact single-column layout'
|
||||
).toBeHidden()
|
||||
'tool label should remain visible in compact layout'
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const labelBox = await toolLabel.boundingBox()
|
||||
const brushBox = await brushButton.boundingBox()
|
||||
if (!labelBox || !brushBox) return false
|
||||
return labelBox.y + labelBox.height <= brushBox.y
|
||||
},
|
||||
{
|
||||
message: 'label should stack above the brush button in compact layout'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple sequential strokes at different positions all accumulate', async ({
|
||||
|
||||
@@ -558,5 +558,52 @@ test.describe(
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.fail(
|
||||
'Promoted text widget is removed when source node is deleted inside the subgraph',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('New Subgraph')
|
||||
.first()
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const subgraphNodeId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('text')
|
||||
await expect(
|
||||
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
)
|
||||
await interiorClip.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const subgraphNodeAfter =
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(subgraphNodeAfter).toBeVisible()
|
||||
await expect(
|
||||
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
@@ -188,4 +189,79 @@ test.describe('Workflow tabs', () => {
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test.describe('Closing a modified workflow tab (FE-419)', () => {
|
||||
async function modifyActiveWorkflow(page: Page, activeTab: Locator) {
|
||||
await page.evaluate(() => {
|
||||
const graph = window.app?.graph
|
||||
const node = window.LiteGraph?.createNode('Note')
|
||||
if (graph && node) graph.add(node)
|
||||
})
|
||||
await expect(
|
||||
activeTab.getByTestId('workflow-dirty-indicator')
|
||||
).toHaveCount(1)
|
||||
}
|
||||
|
||||
test('shows "Close anyway" label and no Cancel button on dirtyClose dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Close anyway' })
|
||||
).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Save' })).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Cancel' })).toHaveCount(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('clicking "Close anyway" closes the tab without saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
await comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Close anyway' })
|
||||
.click()
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
await expect
|
||||
.poll(() => topbar.getActiveTabName())
|
||||
.toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('dismissing the dialog keeps the modified tab open', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await modifyActiveWorkflow(comfyPage.page, topbar.getActiveTab())
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(comfyPage.page.getByRole('dialog')).toBeHidden()
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -39,6 +41,19 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
const dragFromTabButton = async (comfyPage: ComfyPage, button: Locator) => {
|
||||
const box = await button.boundingBox()
|
||||
if (!box) throw new Error('Tab button has no bounding box')
|
||||
const start = {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height * 0.75
|
||||
}
|
||||
await comfyPage.canvasOps.dragAndDrop(start, {
|
||||
x: start.x + 120,
|
||||
y: start.y + 80
|
||||
})
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -90,6 +105,63 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should not toggle advanced inputs when dragging by the Advanced button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
await comfyPage.nodeOps.addNode(
|
||||
'ModelSamplingFlux',
|
||||
{},
|
||||
{
|
||||
x: 500,
|
||||
y: 200
|
||||
}
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
const showButton = node.getByText('Show advanced inputs')
|
||||
const widgets = node.locator('.lg-node-widget')
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const beforePos = await node.boundingBox()
|
||||
if (!beforePos) throw new Error('Node has no bounding box')
|
||||
|
||||
await dragFromTabButton(comfyPage, showButton)
|
||||
|
||||
await expect(showButton).toBeVisible()
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeHidden()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
const afterPos = await node.boundingBox()
|
||||
if (!afterPos) throw new Error('Node missing after drag')
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should not enter subgraph when dragging by the Enter Subgraph button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const beforePos = await subgraphNode.getPosition()
|
||||
|
||||
await dragFromTabButton(
|
||||
comfyPage,
|
||||
comfyPage.vueNodes.getSubgraphEnterButton('2')
|
||||
)
|
||||
|
||||
expect(await comfyPage.subgraph.isInSubgraph()).toBe(false)
|
||||
|
||||
const afterPos = await subgraphNode.getPosition()
|
||||
await expectPosChanged(beforePos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -249,6 +249,7 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
|
||||
|
||||
328
docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 9. Subgraph promoted widgets use linked inputs
|
||||
|
||||
Date: 2026-05-05
|
||||
|
||||
Appendices:
|
||||
|
||||
- [Before/after flow diagrams](./0009-subgraph-promoted-widgets-use-linked-inputs/before-after-flows.md)
|
||||
- [System comparison](./0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md)
|
||||
- [Removing `disambiguatingSourceNodeId`](./0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md)
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Subgraph widget promotion historically had two overlapping representations:
|
||||
|
||||
1. `properties.proxyWidgets`, a serialized list of source node/widget tuples;
|
||||
2. linked subgraph inputs, where an interior widget-bearing input is exposed
|
||||
through the subgraph boundary.
|
||||
|
||||
This created ambiguous ownership. Runtime value reads could collapse to an
|
||||
interior source widget, while host `widgets_values` could also carry an
|
||||
exterior value. Multiple host instances of the same subgraph could therefore
|
||||
stomp one another, and serialization could mutate interior widgets as a
|
||||
persistence carrier for exterior values.
|
||||
|
||||
The ECS widget migration makes that ambiguity more expensive: widgets are
|
||||
becoming entities with component state keyed by stable entity identity, and
|
||||
subgraphs are modeled as graph boundary structure rather than a separate
|
||||
promotion-specific entity kind.
|
||||
|
||||
## Decision
|
||||
|
||||
Promoted widgets are represented only as standard linked `SubgraphInput`
|
||||
widgets. A promoted widget is a host-scoped widget entity owned by a subgraph
|
||||
input on a host `SubgraphNode`. The interior source widget supplies schema,
|
||||
type, options, tooltip, and default metadata, but it is not the owner of the
|
||||
host value.
|
||||
|
||||
Display-only preview surfacing, such as `$$canvas-image-preview`, is not a
|
||||
promoted widget. It is a separate preview-exposure system because it has no
|
||||
host-owned widget value, does not feed prompt serialization, and often points at
|
||||
virtual `serialize: false` pseudo-widgets that may not exist on the source node.
|
||||
|
||||
`properties.proxyWidgets` becomes a legacy load-time input only. Successful
|
||||
repair consumes entries from `proxyWidgets`; canonical saves do not re-emit
|
||||
those entries. The standard serialized representation is the existing subgraph
|
||||
interface/input form plus host-node `widgets_values`.
|
||||
|
||||
Display-only preview exposures use their own host-node-scoped serialized entry,
|
||||
`properties.previewExposures`, instead of `properties.proxyWidgets` and instead
|
||||
of linked `SubgraphInput` widgets. Canonical preview-exposure JSON uses preview
|
||||
language, not widget language:
|
||||
|
||||
```ts
|
||||
type PreviewExposure = {
|
||||
name: string
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}
|
||||
```
|
||||
|
||||
Host-node scope preserves current behavior where different instances of the
|
||||
same subgraph can choose different exposed previews.
|
||||
|
||||
The entry intentionally stores only host preview identity and source locator
|
||||
identity. `name` is the host-scoped stable identity for this preview exposure,
|
||||
analogous to `SubgraphInput.name`; it is not a display label. It is generated
|
||||
with existing collision behavior, such as `nextUniqueName(...)`, when an
|
||||
exposure is created. Media type, display labels, titles, image/video/audio URLs,
|
||||
and other runtime preview details are derived from the current graph and output
|
||||
state. Array order is the canonical display order. Preview exposures do not get
|
||||
a separate persisted `label` in this slice; if a future rename UX needs one, it
|
||||
should follow the same rule as subgraph inputs: `name` is identity and `label`
|
||||
is display-only.
|
||||
|
||||
Preview exposures are persisted user choices after creation. Packing nodes into
|
||||
a subgraph may auto-add recommended preview exposures for supported output
|
||||
nodes, and users may explicitly add or remove additional preview exposures
|
||||
afterward. Normal load/save does not re-derive previews from node type alone,
|
||||
because that would make old workflows change when support for new preview node
|
||||
types is added. Unresolved preview exposures remain persisted and inert;
|
||||
automatic cleanup does not prune them. They are removed only by explicit user
|
||||
action or by destruction/unpacking of the owning host.
|
||||
|
||||
Preview exposures compose through nested subgraph hosts by chaining immediate
|
||||
boundaries. If an outer subgraph wants to show a preview exposed by an inner
|
||||
subgraph host, the outer `previewExposures` entry points at the immediate inner
|
||||
`SubgraphNode`, and `sourcePreviewName` names the inner host's preview-exposure
|
||||
identity, not the deepest interior preview name. Runtime preview resolution may
|
||||
then follow the inner host's own preview exposures to find media. Canonical JSON
|
||||
does not persist flattened deep paths, because deep paths would couple host UI
|
||||
state to private nested graph internals.
|
||||
|
||||
## Identity and value ownership
|
||||
|
||||
- UI/value identity is host-scoped: host node locator plus
|
||||
`SubgraphInput.name`.
|
||||
- Host-scoped identity means the host `SubgraphNode` instance within its
|
||||
containing `graphScope`; the interior source node is not the state or
|
||||
persistence owner.
|
||||
- `SubgraphInput.name` is the stable internal identity.
|
||||
- `SubgraphInput.label` / `localized_name` are display-only.
|
||||
- `SubgraphInput.id` may be used for slot-instance reconciliation, not as the
|
||||
persisted widget value key.
|
||||
- Source node/widget identity remains metadata for diagnostics, missing-model
|
||||
lookup, schema projection, and migration only.
|
||||
- The host/exterior value wins over the interior/source value during repair,
|
||||
persistence, and prompt serialization.
|
||||
|
||||
This follows the existing widget/slot convention: `name` is identity, `label`
|
||||
is display.
|
||||
|
||||
Promoted-widget value state is a host-scoped sparse overlay over source-widget
|
||||
metadata and defaults. The source widget remains the schema/default provider;
|
||||
host value state is materialized only when the exterior value differs from the
|
||||
effective source default or when restored from persisted host state. Canonical
|
||||
save/load must not eagerly mirror source defaults or use interior widgets as
|
||||
persistence carriers.
|
||||
|
||||
## Forward migration
|
||||
|
||||
Loading a workflow with legacy `proxyWidgets` runs a one-way repair:
|
||||
|
||||
1. Parse `properties.proxyWidgets` with the existing Zod-inferred tuple type.
|
||||
2. Invalid raw `proxyWidgets` data logs `console.error`, does not throw, and is
|
||||
not quarantined.
|
||||
3. Build a multi-pass association map before mutation:
|
||||
- normalized legacy proxy entry;
|
||||
- projected legacy promoted-widget order;
|
||||
- host `widgets_values` value, preserving sparse holes;
|
||||
- repair strategy or failure reason;
|
||||
- whether the entry is a value widget or display-only preview exposure.
|
||||
4. Defer mutations until node IDs/entity IDs are stable and the subgraph graph
|
||||
is configured.
|
||||
5. On flush, re-resolve against current graph state, because clone/paste/load
|
||||
flows may have remapped or created nodes and links.
|
||||
6. If already represented by a linked `SubgraphInput`, consider the legacy
|
||||
entry resolved and consume it.
|
||||
7. Otherwise repair through existing subgraph input/link systems.
|
||||
8. If the entry is display-only preview surfacing, migrate it into the separate
|
||||
preview-exposure representation instead of creating a linked `SubgraphInput`.
|
||||
9. If value-widget repair fails, write inert quarantine metadata and warn.
|
||||
|
||||
The repair is idempotent. Pending plans store tuple/value data and re-check the
|
||||
current graph before applying mutations.
|
||||
|
||||
Legacy entries are classified as preview exposures when either:
|
||||
|
||||
- the legacy source name starts with `$$`; or
|
||||
- the source node resolves to a matching pseudo-preview widget, such as a
|
||||
`serialize: false` preview/video/audio UI widget.
|
||||
|
||||
Everything else is treated as a value-widget promotion candidate. An unresolved
|
||||
preview-shaped entry remains inert at runtime and is still persisted, because
|
||||
preview-capable pseudo-widgets and output media can be removed and re-added
|
||||
dynamically. It is not quarantined because it has no user value to preserve. A
|
||||
non-`$$` entry that cannot resolve to a source widget is a value-widget repair
|
||||
failure and follows the quarantine path unless it can resolve to a
|
||||
pseudo-preview widget.
|
||||
|
||||
## Proxy widget error quarantine
|
||||
|
||||
Valid legacy entries that cannot be repaired are persisted in
|
||||
`properties.proxyWidgetErrorQuarantine`. Quarantined entries are inert: they do
|
||||
not hydrate runtime promoted widgets, do not participate in execution, and are
|
||||
not used for app-mode/favorites identity.
|
||||
|
||||
Quarantine entries preserve enough information to avoid data loss and support
|
||||
future tooling:
|
||||
|
||||
```ts
|
||||
type ProxyWidgetErrorQuarantineEntry = {
|
||||
originalEntry: ProxyWidgetTuple
|
||||
reason:
|
||||
| 'missingSourceNode'
|
||||
| 'missingSourceWidget'
|
||||
| 'missingSubgraphInput'
|
||||
| 'ambiguousSubgraphInput'
|
||||
| 'unlinkedSourceWidget'
|
||||
| 'primitiveBypassFailed'
|
||||
hostValue?: TWidgetValue
|
||||
attemptedAtVersion: 1
|
||||
}
|
||||
```
|
||||
|
||||
Unresolved legacy UI selections/favorites are dropped with `console.warn`.
|
||||
Workflow-level promotion/value intent is preserved by
|
||||
`proxyWidgetErrorQuarantine`, not by a second UI quarantine format.
|
||||
|
||||
## Primitive-node repair
|
||||
|
||||
Legacy `proxyWidgets` may point at `PrimitiveNode` outputs. Primitive nodes
|
||||
serve nearly the same purpose as subgraph inputs: they provide a widget value to
|
||||
one or more target widget inputs. The migration repairs this expected legacy
|
||||
shape in the first migration rather than quarantining it by default.
|
||||
|
||||
Primitive repair:
|
||||
|
||||
- coalesces exact duplicate legacy entries during planning;
|
||||
- uses the primitive node's user title as the base input name when the node was
|
||||
renamed, otherwise the primitive output widget name;
|
||||
- applies existing naming behavior and `nextUniqueName(...)` for collisions;
|
||||
- uses the existing primitive merge/config compatibility logic;
|
||||
- creates one `SubgraphInput` for the primitive fanout;
|
||||
- reconnects every former primitive output target to that input in target
|
||||
order, using standard connect/disconnect APIs;
|
||||
- applies the host value when one exists, otherwise seeds from the source
|
||||
primitive value;
|
||||
- leaves the primitive node and its widget value in place, but disconnected and
|
||||
inert.
|
||||
|
||||
Primitive repair is all-or-quarantine. If any target cannot be validated or
|
||||
reconnected, the migration does not leave a partial rewrite; it quarantines the
|
||||
entry with `hostValue` and logs the reason.
|
||||
|
||||
## Serialization
|
||||
|
||||
After repair/quarantine:
|
||||
|
||||
- `properties.proxyWidgets` is omitted for repaired entries;
|
||||
- display-only preview entries are omitted from `properties.proxyWidgets` and
|
||||
emitted through `properties.previewExposures`;
|
||||
- `properties.proxyWidgetErrorQuarantine` carries unrepaired valid entries;
|
||||
- preview exposures do not carry quarantine values because they do not own user
|
||||
values; unresolved preview exposures remain inert in `previewExposures`;
|
||||
- host `widgets_values` contains host-owned values only for canonical host
|
||||
widgets, not source-owned defaults or interior persistence copies;
|
||||
- quarantined legacy values live in `proxyWidgetErrorQuarantine.hostValue`;
|
||||
- array-form `widgets_values` remains for now.
|
||||
|
||||
Preview exposures are display-only UI metadata. They drive host canvas/app-mode
|
||||
preview rendering, but they do not create prompt inputs, do not create
|
||||
`widgets_values`, do not alter node execution order, do not become executable
|
||||
graph edges, and do not participate in prompt serialization. Runtime mapping
|
||||
from backend `display_node`/output messages to a host preview exposure is a UI
|
||||
projection only.
|
||||
|
||||
The old `SubgraphNode.serialize()` behavior that copied exterior promoted
|
||||
values into connected interior widgets is removed. A temporary TODO should mark
|
||||
that removal point until the migration is proven stable. Host values are
|
||||
serialized through standard subgraph-input widgets instead.
|
||||
|
||||
Longer term, `widgets_values` should move from array order to an object/map
|
||||
keyed by stable widget name, but that migration is out of scope for this
|
||||
decision.
|
||||
|
||||
## App mode, builder, and favorites
|
||||
|
||||
The runtime migration and UI identity migration ship in the same slice. The UI
|
||||
must not persist promoted selections by source node/widget identity after this
|
||||
change.
|
||||
|
||||
Canonical UI identity is:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
subgraphInputName: string
|
||||
}
|
||||
```
|
||||
|
||||
Legacy source-identity selections are migrated when they resolve through the
|
||||
standard input created or confirmed by the migration. Unresolved selections are
|
||||
dropped with a warning.
|
||||
|
||||
Preview exposure output selections are also host-scoped and must not persist
|
||||
interior source node identity. Canonical preview/output identity is:
|
||||
|
||||
```ts
|
||||
type PreviewExposureUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
previewName: string
|
||||
}
|
||||
```
|
||||
|
||||
The UI references the explicit preview exposure itself. This keeps subgraphs
|
||||
opaque: consumers select the host boundary contract, not the interior node that
|
||||
currently supplies media. Legacy output selections that refer to interior
|
||||
preview source nodes may migrate if they resolve to a preview-exposure chain;
|
||||
otherwise they are dropped with `console.warn`. There is no separate preview UI
|
||||
quarantine.
|
||||
|
||||
## PromotionStore
|
||||
|
||||
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
|
||||
runtime compatibility/index layer for existing consumers, but it is not
|
||||
serialized authority, must not create promotions without linked
|
||||
`SubgraphInput`s, and should be removed once consumers query the standard graph
|
||||
interface directly.
|
||||
|
||||
## Considered options
|
||||
|
||||
### Keep `proxyWidgets` as canonical serialized topology
|
||||
|
||||
Rejected. This preserves two representations for the same concept and keeps
|
||||
source-widget identity in the value-ownership path.
|
||||
|
||||
### Preserve bare promoted widgets as degraded runtime state
|
||||
|
||||
Rejected. This would avoid some migration complexity, but it perpetuates the
|
||||
ambiguity that caused host/source value bugs and makes ECS identity less clear.
|
||||
|
||||
### Quarantine primitive-node promotions by default
|
||||
|
||||
Rejected. Primitive-node proxy promotions are expected legacy workflows, and
|
||||
quarantining them would break users unnecessarily. They are repaired by bypassing
|
||||
the primitive node when the repair can be validated all-or-nothing.
|
||||
|
||||
### Migrate `widgets_values` to object/map form now
|
||||
|
||||
Rejected for this slice. Name-keyed object form is the desired long-term
|
||||
direction, but combining it with the promotion migration increases blast radius
|
||||
for existing workflow consumers that still assume array order.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Promoted widget values become host-instance-owned and ECS-compatible.
|
||||
- Source widgets remain metadata/default providers, not persistence carriers.
|
||||
- Legacy workflows are repaired toward one standard representation.
|
||||
- Quarantine preserves unrepaired valid legacy data without reintroducing bare
|
||||
runtime promotion.
|
||||
- Primitive fanout repair is more complex, but avoids breaking common existing
|
||||
workflows.
|
||||
- UI code must migrate with the runtime migration to avoid mixed identity states.
|
||||
- `PromotionStore` has a clear removal path.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Appendix: Before and after flows
|
||||
|
||||
This appendix visualizes the ownership and migration flows described in
|
||||
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
|
||||
## Before: proxy widgets and linked inputs overlap
|
||||
|
||||
Historically, promoted widgets could be represented both as serialized
|
||||
`properties.proxyWidgets` entries and as linked subgraph inputs. Runtime value
|
||||
reads could collapse back to the interior source widget, while host
|
||||
`widgets_values` could also carry an exterior value for the same promoted UI.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
|
||||
promotionStore --> sourceWidget[Interior source widget]
|
||||
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
|
||||
sourceWidget --> hostWidget
|
||||
hostValues --> hostWidget
|
||||
hostWidget --> prompt[Prompt serialization]
|
||||
hostWidget -. may copy value back .-> sourceWidget
|
||||
sourceWidget -. shared by host instances .-> otherHost[Another host instance]
|
||||
|
||||
classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
|
||||
class proxyWidgets,promotionStore legacy
|
||||
class sourceWidget,hostValues ambiguous
|
||||
class linkedInput,hostWidget canonical
|
||||
```
|
||||
|
||||
Key problems in the old flow:
|
||||
|
||||
- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
|
||||
the same promotion.
|
||||
- Interior source widgets supplied both schema metadata and, in some flows,
|
||||
persisted host values.
|
||||
- Multiple host instances of the same subgraph could stomp one another through
|
||||
the shared interior widget value.
|
||||
- Display-only previews were mixed into widget-promotion language even though
|
||||
they do not own values or feed prompt serialization.
|
||||
|
||||
## After: linked inputs are the promoted-widget boundary
|
||||
|
||||
Promoted value widgets are now represented only as standard linked
|
||||
`SubgraphInput` widgets. The source widget remains the schema/default provider,
|
||||
but the host `SubgraphNode` owns the promoted value.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
subgraphInterface --> subgraphInput[SubgraphInput.name]
|
||||
subgraphInput --> hostWidget[Host-scoped widget entity]
|
||||
hostValues --> hostWidget
|
||||
sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
|
||||
schema --> hostWidget
|
||||
hostWidget --> prompt[Prompt serialization]
|
||||
|
||||
hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
|
||||
sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
|
||||
sourceWidget -. no host value ownership .-> schema
|
||||
|
||||
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
|
||||
class sourceWidget,schema,diagnostics metadata
|
||||
class workflow,hostValues persisted
|
||||
```
|
||||
|
||||
Canonical ownership after the migration:
|
||||
|
||||
- UI/value identity is host-scoped: host node locator plus
|
||||
`SubgraphInput.name`.
|
||||
- `SubgraphInput.name` is stable identity; labels and localized names are
|
||||
display-only.
|
||||
- Host values win during repair, persistence, and prompt serialization.
|
||||
- Source widgets provide metadata and defaults only.
|
||||
- Canonical saves omit repaired `properties.proxyWidgets` entries.
|
||||
|
||||
## Legacy load migration
|
||||
|
||||
Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
|
||||
repair builds a plan before mutating graph state, then re-resolves against the
|
||||
current graph when node IDs and links are stable.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
start[Load workflow] --> parse{Parse properties.proxyWidgets}
|
||||
parse -->|invalid raw data| invalid[console.error and ignore]
|
||||
parse -->|valid tuples| plan[Build repair plan]
|
||||
plan --> classify{Classify entry}
|
||||
|
||||
classify -->|value widget| valueRepair{Already linked SubgraphInput?}
|
||||
valueRepair -->|yes| consume[Consume legacy proxy entry]
|
||||
valueRepair -->|no| repair[Repair through subgraph input/link systems]
|
||||
repair --> repairResult{Repair succeeded?}
|
||||
repairResult -->|yes| consume
|
||||
repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
|
||||
|
||||
classify -->|primitive fanout| primitive[Validate all primitive targets]
|
||||
primitive --> primitiveResult{All targets reconnectable?}
|
||||
primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
|
||||
primitiveRepair --> consume
|
||||
primitiveResult -->|no| quarantine
|
||||
|
||||
classify -->|display-only preview| preview[Create / keep previewExposures entry]
|
||||
preview --> consume
|
||||
|
||||
consume --> save[Canonical save]
|
||||
quarantine --> save
|
||||
save --> omit[Omit repaired entries from proxyWidgets]
|
||||
save --> keepQuarantine[Persist unrepaired value intent in quarantine]
|
||||
save --> keepPreview[Persist previews in previewExposures]
|
||||
|
||||
classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef error fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
|
||||
class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
|
||||
class quarantine,keepQuarantine warn
|
||||
class invalid error
|
||||
```
|
||||
|
||||
## Preview exposures are separate from value widgets
|
||||
|
||||
Display-only previews, such as `$$canvas-image-preview`, are not promoted
|
||||
widgets. They have host-scoped serialized identity, but they do not create
|
||||
prompt inputs, do not create `widgets_values`, and do not own user values.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
|
||||
previewExposures --> exposure[PreviewExposure.name]
|
||||
exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
|
||||
sourceLocator --> runtimePreview[Runtime preview/output state]
|
||||
runtimePreview --> hostCanvas[Host canvas / app-mode preview]
|
||||
|
||||
exposure --> uiIdentity[hostNodeLocator + previewName]
|
||||
runtimePreview -. UI projection only .-> hostCanvas
|
||||
previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
|
||||
previewExposures -. no value widget .-> noValue[No widgets_values entry]
|
||||
previewExposures -. no graph edge .-> noEdge[No executable graph edge]
|
||||
|
||||
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
|
||||
|
||||
class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
|
||||
class noPrompt,noValue,noEdge noValue
|
||||
class hostNode persisted
|
||||
```
|
||||
|
||||
For nested subgraphs, preview exposures chain across immediate host boundaries
|
||||
instead of persisting flattened deep paths.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
|
||||
outerExposure --> innerHost[Immediate inner SubgraphNode]
|
||||
innerHost --> innerExposure[Inner previewExposures entry]
|
||||
innerExposure --> deepestPreview[Interior preview source]
|
||||
deepestPreview --> media[Resolved media]
|
||||
|
||||
outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
|
||||
outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
|
||||
|
||||
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef preview fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
|
||||
class outerHost,innerHost boundary
|
||||
class outerExposure,innerExposure,deepestPreview,media preview
|
||||
class opaque note
|
||||
```
|
||||
|
||||
## Serialization summary
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
|
||||
canonical --> values[widgets_values for host-owned values]
|
||||
canonical --> previews[properties.previewExposures]
|
||||
canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
|
||||
canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
|
||||
|
||||
inputs --> valueWidgets[Promoted value widgets]
|
||||
values --> valueWidgets
|
||||
previews --> previewUi[Display-only preview UI]
|
||||
quarantine --> futureTooling[Future recovery tooling]
|
||||
|
||||
valueWidgets --> prompt[Prompt serialization]
|
||||
previewUi -. not serialized into prompt .-> prompt
|
||||
quarantine -. inert .-> prompt
|
||||
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
|
||||
|
||||
class inputs,values,valueWidgets,prompt,canonical canonical
|
||||
class previews,previewUi,quarantine,futureTooling inert
|
||||
class noProxy removed
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Appendix: Removing `disambiguatingSourceNodeId`
|
||||
|
||||
This appendix explains where the existing promotion system needs
|
||||
`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
|
||||
chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
|
||||
removes the pattern from promoted-widget identity.
|
||||
|
||||
## Why the disambiguator exists
|
||||
|
||||
The legacy promotion model identifies a promoted widget by source location:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetSource = {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
```
|
||||
|
||||
`sourceNodeId` is the immediate interior node visible from the host subgraph.
|
||||
That is not always the original widget owner. When promotions pass through
|
||||
nested subgraphs, two promoted widgets can have the same immediate
|
||||
`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
|
||||
`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
|
||||
can choose the right promoted view.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
|
||||
middleNode --> middleWidgetA[Promoted widget view: text]
|
||||
middleNode --> middleWidgetB[Promoted widget view: text]
|
||||
middleWidgetA --> leafA[Leaf source node 17 / widget text]
|
||||
middleWidgetB --> leafB[Leaf source node 42 / widget text]
|
||||
|
||||
oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
|
||||
oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
|
||||
middleWidgetA -. requires .-> oldKeyA
|
||||
middleWidgetB -. requires .-> oldKeyB
|
||||
|
||||
classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
|
||||
|
||||
class outerHost host
|
||||
class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
|
||||
class leafA,leafB leaf
|
||||
```
|
||||
|
||||
The disambiguator is therefore not a domain concept. It is compensating for an
|
||||
identity model that asks host UI state to identify private nested internals.
|
||||
|
||||
## Existing places that need it
|
||||
|
||||
| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
|
||||
| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
|
||||
| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
|
||||
| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
|
||||
| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
|
||||
| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
|
||||
|
||||
## New promoted-widget identity
|
||||
|
||||
ADR 0009 moves promoted value identity to the host boundary:
|
||||
|
||||
```ts
|
||||
type PromotedWidgetUiIdentity = {
|
||||
hostNodeLocator: string
|
||||
subgraphInputName: string
|
||||
}
|
||||
```
|
||||
|
||||
The canonical widget is owned by a `SubgraphInput` on the host
|
||||
`SubgraphNode`. The host widget no longer needs to identify the deepest source
|
||||
node to preserve value identity. The source widget is consulted for schema,
|
||||
defaults, diagnostics, and migration, but it is not the value owner.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
|
||||
host --> inputB[SubgraphInput.name: negative_prompt]
|
||||
inputA --> hostWidgetA[Host-owned widget entity]
|
||||
inputB --> hostWidgetB[Host-owned widget entity]
|
||||
|
||||
hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
|
||||
hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
|
||||
|
||||
identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
|
||||
identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
|
||||
sourceA -. not part of host value key .-> identityA
|
||||
sourceB -. not part of host value key .-> identityB
|
||||
|
||||
classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef removed fill:#f8d7da,stroke:#842029,color:#330000
|
||||
|
||||
class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
|
||||
class sourceA,sourceB metadata
|
||||
```
|
||||
|
||||
This is the same rule the subgraph interface already uses: `name` is stable
|
||||
identity, and `label` / `localized_name` are display-only.
|
||||
|
||||
## How the new form removes each need
|
||||
|
||||
| Previous disambiguation site | New canonical replacement |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
|
||||
| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
|
||||
| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
|
||||
| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
|
||||
| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
|
||||
| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
|
||||
| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
|
||||
|
||||
## Boundary-by-boundary nested flow
|
||||
|
||||
The new form avoids flattened deep source paths. Each host boundary exposes its
|
||||
own named input, and the next outer host links to that immediate boundary
|
||||
contract.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
|
||||
innerInput --> innerHostWidget[Inner host-owned widget]
|
||||
innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
|
||||
outerInput --> outerHostWidget[Outer host-owned widget]
|
||||
|
||||
innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
|
||||
outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
|
||||
leaf -. schema/default source .-> innerHostWidget
|
||||
leaf -. not persisted as outer value key .-> outerIdentity
|
||||
|
||||
classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
classDef source fill:#cff4fc,stroke:#055160,color:#032830
|
||||
classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
|
||||
|
||||
class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
|
||||
class leaf source
|
||||
```
|
||||
|
||||
Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
|
||||
widgets no longer require a persisted leaf-node disambiguator at the outer host.
|
||||
If the user exposes both, the collision is resolved when the host inputs are
|
||||
created by assigning distinct input names with the existing unique-name
|
||||
behavior.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Appendix: System comparison
|
||||
|
||||
This appendix compares the legacy promoted-widget systems with the canonical
|
||||
linked-input model chosen by
|
||||
[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
|
||||
| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
|
||||
| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
|
||||
| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
|
||||
| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
|
||||
| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
|
||||
| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
|
||||
| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
|
||||
| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
|
||||
| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
|
||||
| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
|
||||
| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
|
||||
| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
|
||||
| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
|
||||
| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
|
||||
| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
|
||||
| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
|
||||
| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
|
||||
|
||||
## Practical migration summary
|
||||
|
||||
| Legacy shape | New result |
|
||||
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
|
||||
| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
|
||||
| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
|
||||
| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
|
||||
| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
|
||||
| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
|
||||
| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |
|
||||
@@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
|
||||
the ID mapping — widgets currently lack independent IDs, so the bridge must
|
||||
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
|
||||
|
||||
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
|
||||
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
|
||||
source node/widget identity is preserved only as migration and diagnostic
|
||||
metadata.
|
||||
|
||||
### 2c. Read-only bridge for Node metadata
|
||||
|
||||
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
|
||||
@@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies:
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
|
||||
| SubgraphNavigationStore | subgraphId or `'root'` |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
source node/widget identity.
|
||||
|
||||
The World unifies these under branded entity IDs. But stores that use
|
||||
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
|
||||
reality — a widget is identified by its relationship to a node. Synthetic
|
||||
|
||||
@@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
source node/widget identity is migration and diagnostic metadata only.
|
||||
|
||||
## 2. WidgetValueStore
|
||||
|
||||
**File:** `src/stores/widgetValueStore.ts`
|
||||
@@ -254,6 +258,9 @@ Each store invents its own identity scheme:
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
identity (`host node locator + SubgraphInput.name`) instead of interior source
|
||||
identity.
|
||||
|
||||
## 6. Extraction Map
|
||||
|
||||
|
||||
@@ -404,26 +404,21 @@ Whichever candidate is chosen:
|
||||
instance-specific state beyond inputs — must remain reachable. This is a
|
||||
constraint, not a current requirement.
|
||||
|
||||
### Recommendation and decision criteria
|
||||
### Decision
|
||||
|
||||
**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
|
||||
truth: promotion is adding a typed input to a function signature. The type
|
||||
system already handles widget creation for typed inputs. Building a parallel
|
||||
mechanism for "promoted widgets" is building a second, narrower version of
|
||||
something the system already does.
|
||||
[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
|
||||
chooses Candidate A for promoted value widgets. It eliminates an entire
|
||||
subsystem by recognizing a structural truth: promotion is adding a typed input
|
||||
to a function signature. The type system already handles widget creation for
|
||||
typed inputs. Building a parallel mechanism for "promoted widgets" is building
|
||||
a second, narrower version of something the system already does.
|
||||
|
||||
The cost of A is a migration path for existing `proxyWidgets` serialization. On
|
||||
load, the `SerializationSystem` converts `proxyWidgets` entries into interface
|
||||
inputs and boundary links. This is a one-time ratchet conversion — once
|
||||
loaded and re-saved, the workflow uses the new format.
|
||||
|
||||
**Choose B if** the team determines that promoted widgets must remain
|
||||
visually or behaviorally distinct from normal input widgets in ways the type →
|
||||
widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
|
||||
the current release cycle's capacity.
|
||||
|
||||
**Decision needed before** Phase 3 of the ECS migration, when systems are
|
||||
introduced and the widget/connectivity architecture solidifies.
|
||||
load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
|
||||
into interface inputs and boundary links. Once loaded and re-saved, the workflow
|
||||
uses the new format. ADR 0009 separates display-only preview exposures from
|
||||
promoted value widgets; those previews use their own host-scoped serialized
|
||||
representation instead of linked `SubgraphInput` widgets.
|
||||
|
||||
---
|
||||
|
||||
@@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
|
||||
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
|
||||
see no change.
|
||||
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ---------------------------------------- |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ------------------------------------------ |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
|
||||
|
||||
The "ratchet conversion" pattern: load any supported format, normalize to the
|
||||
internal model. The system accepts old formats indefinitely but produces the
|
||||
current format on save.
|
||||
The migration pattern: load any supported format and normalize to the internal
|
||||
model. The system accepts old formats indefinitely but produces the current
|
||||
format on save.
|
||||
|
||||
### Widget identity at the boundary
|
||||
|
||||
@@ -511,13 +506,12 @@ SubgraphIO {
|
||||
}
|
||||
```
|
||||
|
||||
If Candidate A (connections-only promotion) is chosen: promoted widgets become
|
||||
interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
|
||||
`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
|
||||
migration). On save, `proxyWidgets` is no longer written.
|
||||
|
||||
If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
|
||||
serialized in its current format.
|
||||
ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
|
||||
widgets: they become interface inputs, serialized as additional `SubgraphIO`
|
||||
entries. On load, legacy value-widget `proxyWidgets` data is converted to
|
||||
interface inputs and boundary links. On save, repaired `proxyWidgets` entries
|
||||
are no longer written. Display-only preview exposures use separate
|
||||
host-scoped `previewExposures` serialization.
|
||||
|
||||
### Backward-compatible loading contract
|
||||
|
||||
@@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to
|
||||
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
|
||||
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
|
||||
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
|
||||
| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
|
||||
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
|
||||
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
|
||||
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.18",
|
||||
"version": "1.45.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
|
||||
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
getPathDetails,
|
||||
highlightQuery,
|
||||
isCivitaiModelUrl,
|
||||
isPreviewableMediaType,
|
||||
joinFilePath,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinFilePath', () => {
|
||||
it('joins subfolder and filename with normalized slash separators', () => {
|
||||
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
||||
'nested/folder/child/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('trims boundary separators without changing the filename body', () => {
|
||||
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
||||
'nested/folder/file.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the normalized filename when no subfolder is provided', () => {
|
||||
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
||||
})
|
||||
|
||||
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
||||
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
||||
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFilePathSeparatorVariants', () => {
|
||||
it('returns slash and backslash variants for nested paths', () => {
|
||||
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
||||
'nested/folder/file.png',
|
||||
'nested\\folder\\file.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single value when no separator is present', () => {
|
||||
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendWorkflowJsonExt', () => {
|
||||
it('appends .app.json when isApp is true', () => {
|
||||
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
||||
|
||||
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function joinFilePath(
|
||||
subfolder: string | null | undefined,
|
||||
filename: string | null | undefined
|
||||
): string {
|
||||
const normalizedSubfolder = normalizeFilePathSeparators(
|
||||
subfolder ?? ''
|
||||
).replace(/^\/+|\/+$/g, '')
|
||||
const normalizedFilename = normalizeFilePathSeparators(
|
||||
filename ?? ''
|
||||
).replace(/^\/+/g, '')
|
||||
if (!normalizedSubfolder) return normalizedFilename
|
||||
if (!normalizedFilename) return normalizedSubfolder
|
||||
return `${normalizedSubfolder}/${normalizedFilename}`
|
||||
}
|
||||
|
||||
export function getFilePathSeparatorVariants(filepath: string): string[] {
|
||||
const slashPath = normalizeFilePathSeparators(filepath)
|
||||
const backslashPath = slashPath.replace(/\//g, '\\')
|
||||
return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
|
||||
}
|
||||
|
||||
function normalizeFilePathSeparators(filepath: string): string {
|
||||
return filepath.replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a filepath into its filename and subfolder components.
|
||||
*
|
||||
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
|
||||
} {
|
||||
if (!filepath?.trim()) return { filename: '', subfolder: '' }
|
||||
|
||||
const normalizedPath = filepath
|
||||
.replace(/[\\/]+/g, '/') // Normalize path separators
|
||||
const normalizedPath = normalizeFilePathSeparators(filepath)
|
||||
.replace(/^\//, '') // Remove leading slash
|
||||
.replace(/\/$/, '') // Remove trailing slash
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"LoadImage": 3474,
|
||||
"CLIPTextEncode": 2435,
|
||||
"SaveImage": 1762,
|
||||
"SaveImageAdvanced": 1762,
|
||||
"VAEDecode": 1754,
|
||||
"KSampler": 1511,
|
||||
"CheckpointLoaderSimple": 1293,
|
||||
|
||||
@@ -19,6 +19,7 @@ import subprocess
|
||||
|
||||
import av
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||
@@ -115,6 +116,15 @@ def generate_av_fixture(
|
||||
report(name)
|
||||
|
||||
|
||||
def generate_png():
|
||||
img = make_1x1_image()
|
||||
info = PngInfo()
|
||||
info.add_text('workflow', WORKFLOW_JSON)
|
||||
info.add_text('prompt', PROMPT_JSON)
|
||||
img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
|
||||
report('with_metadata.png')
|
||||
|
||||
|
||||
def generate_webp():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
@@ -167,6 +177,7 @@ def generate_webm():
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_png()
|
||||
generate_webp()
|
||||
generate_avif()
|
||||
generate_flac()
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
:size="item.dialogComponentProps.size ?? 'md'"
|
||||
:class="item.dialogComponentProps.contentClass"
|
||||
:aria-labelledby="item.key"
|
||||
@escape-key-down="
|
||||
(e) =>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import ConfirmationDialogContent from './ConfirmationDialogContent.vue'
|
||||
|
||||
type Props = ComponentProps<typeof ConfirmationDialogContent>
|
||||
@@ -12,7 +13,23 @@ type Props = ComponentProps<typeof ConfirmationDialogContent>
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
delete: 'Delete',
|
||||
overwrite: 'Overwrite',
|
||||
save: 'Save',
|
||||
no: 'No',
|
||||
ok: 'OK',
|
||||
close: 'Close'
|
||||
},
|
||||
desktopMenu: {
|
||||
reinstall: 'Reinstall'
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
@@ -23,10 +40,9 @@ describe('ConfirmationDialogContent', () => {
|
||||
})
|
||||
|
||||
function renderComponent(props: Partial<Props> = {}) {
|
||||
return render(ConfirmationDialogContent, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n]
|
||||
},
|
||||
const user = userEvent.setup()
|
||||
render(ConfirmationDialogContent, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
message: 'Test message',
|
||||
type: 'default',
|
||||
@@ -34,6 +50,7 @@ describe('ConfirmationDialogContent', () => {
|
||||
...props
|
||||
} as Props
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
it('renders long messages without breaking layout', () => {
|
||||
@@ -42,4 +59,104 @@ describe('ConfirmationDialogContent', () => {
|
||||
renderComponent({ message: longFilename })
|
||||
expect(screen.getByText(longFilename)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the hint as a status alert when provided', () => {
|
||||
renderComponent({ hint: 'This action cannot be undone.' })
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('This action cannot be undone.')
|
||||
})
|
||||
|
||||
it('does not render a status alert when hint is omitted', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('button surface per type', () => {
|
||||
it("type='default' renders Cancel and Confirm", () => {
|
||||
renderComponent({ type: 'default' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Confirm' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='delete' renders Cancel and Delete", () => {
|
||||
renderComponent({ type: 'delete' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='overwrite' renders Cancel and Overwrite", () => {
|
||||
renderComponent({ type: 'overwrite' })
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Overwrite' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='dirtyClose' renders No and Save (no Cancel)", () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("type='info' renders only OK (no Cancel)", () => {
|
||||
renderComponent({ type: 'info' })
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Cancel' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('confirm callback receives true and closes the dialog', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ type: 'default', onConfirm })
|
||||
const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
expect(closeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('dirtyClose deny label', () => {
|
||||
it('uses the provided denyLabel for the deny button', () => {
|
||||
renderComponent({ type: 'dirtyClose', denyLabel: 'Sign out anyway' })
|
||||
expect(screen.getByText('Sign out anyway')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'No' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to "No" when denyLabel is not provided', () => {
|
||||
renderComponent({ type: 'dirtyClose' })
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm(false) when deny is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
type: 'dirtyClose',
|
||||
denyLabel: 'Close anyway',
|
||||
onConfirm
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close anyway' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('calls onConfirm(true) when save is clicked', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { user } = renderComponent({ type: 'dirtyClose', onConfirm })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,16 +9,14 @@
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<Message
|
||||
<div
|
||||
v-if="hint"
|
||||
class="mt-2"
|
||||
icon="pi pi-info-circle"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
variant="simple"
|
||||
role="status"
|
||||
class="mt-2 flex items-start gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<i class="pi pi-info-circle mt-0.5" aria-hidden="true" />
|
||||
<span>{{ hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-4">
|
||||
<div
|
||||
@@ -55,7 +53,7 @@
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="type !== 'info'"
|
||||
v-if="type !== 'info' && type !== 'dirtyClose'"
|
||||
variant="secondary"
|
||||
autofocus
|
||||
@click="onCancel"
|
||||
@@ -86,9 +84,9 @@
|
||||
<template v-else-if="type === 'dirtyClose'">
|
||||
<Button variant="secondary" @click="onDeny">
|
||||
<i class="pi pi-times" />
|
||||
{{ $t('g.no') }}
|
||||
{{ denyLabel ?? $t('g.no') }}
|
||||
</Button>
|
||||
<Button @click="onConfirm">
|
||||
<Button autofocus @click="onConfirm">
|
||||
<i class="pi pi-save" />
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
@@ -115,7 +113,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -131,6 +128,7 @@ const props = defineProps<{
|
||||
onConfirm: (value?: boolean) => void
|
||||
itemList?: string[]
|
||||
hint?: string
|
||||
denyLabel?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
164
src/components/helpcenter/HelpCenterMenuContent.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import HelpCenterMenuContent from './HelpCenterMenuContent.vue'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const commandStoreExecute = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: () => ({
|
||||
staticUrls: { discord: '', github: '' },
|
||||
buildDocsUrl: () => 'https://docs.comfy.org'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
trackHelpCenterOpened: vi.fn(),
|
||||
trackHelpCenterClosed: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => ({
|
||||
releases: [],
|
||||
recentReleases: [],
|
||||
isLoading: false,
|
||||
fetchReleases: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: commandStoreExecute })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => null
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => ({
|
||||
useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({ isNewManagerUI: { value: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
|
||||
useComfyManagerService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons/PuzzleIcon.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'PuzzleIconStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(HelpCenterMenuContent, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('HelpCenterMenuContent feedback item', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
commandStoreExecute.mockReset()
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Cloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with help-center source on Nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
expect(commandStoreExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to Comfy.ContactSupport on OSS builds', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' }))
|
||||
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,7 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
@@ -306,7 +307,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
trackResourceClick('help_feedback', isCloud || isNightly)
|
||||
if (isCloud || isNightly) {
|
||||
window.open(
|
||||
'https://form.typeform.com/to/q7azbWPi',
|
||||
buildFeedbackTypeformUrl('help-center'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
334
src/components/painter/WidgetPainter.test.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const sizeHolder = vi.hoisted(() => ({ width: 0, height: 0 }))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useElementSize: () => ({
|
||||
width: ref(sizeHolder.width),
|
||||
height: ref(sizeHolder.height)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const painterHolder = vi.hoisted(() => ({
|
||||
state: null as Record<string, unknown> | null
|
||||
}))
|
||||
|
||||
function createDefaultPainterState() {
|
||||
return {
|
||||
tool: ref('brush'),
|
||||
brushSize: ref(20),
|
||||
brushColor: ref('#000000'),
|
||||
brushOpacity: ref(1),
|
||||
brushHardness: ref(1),
|
||||
backgroundColor: ref('#ffffff'),
|
||||
canvasWidth: ref(512),
|
||||
canvasHeight: ref(512),
|
||||
cursorVisible: ref(true),
|
||||
displayBrushSize: ref(20),
|
||||
inputImageUrl: ref<string | null>(null),
|
||||
isImageInputConnected: ref(false),
|
||||
handlePointerDown: vi.fn(),
|
||||
handlePointerMove: vi.fn(),
|
||||
handlePointerUp: vi.fn(),
|
||||
handlePointerEnter: vi.fn(),
|
||||
handlePointerLeave: vi.fn(),
|
||||
handleInputImageLoad: vi.fn(),
|
||||
handleClear: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/composables/painter/usePainter', () => ({
|
||||
PAINTER_TOOLS: { BRUSH: 'brush', ERASER: 'eraser' } as const,
|
||||
usePainter: () => {
|
||||
if (!painterHolder.state) painterHolder.state = createDefaultPainterState()
|
||||
return painterHolder.state
|
||||
}
|
||||
}))
|
||||
|
||||
import WidgetPainter from './WidgetPainter.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
painter: {
|
||||
tool: 'Tool',
|
||||
brush: 'Brush',
|
||||
eraser: 'Eraser',
|
||||
size: 'Size',
|
||||
color: 'Color',
|
||||
hardness: 'Hardness',
|
||||
width: 'Width',
|
||||
height: 'Height',
|
||||
background: 'Background',
|
||||
clear: 'Clear'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
name: 'Button',
|
||||
inheritAttrs: false,
|
||||
template: '<button v-bind="$attrs" type="button"><slot /></button>'
|
||||
})
|
||||
|
||||
const SliderStub = defineComponent({
|
||||
name: 'Slider',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
min: Number,
|
||||
max: Number,
|
||||
step: Number
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<div data-testid="slider-stub" :data-min="min" @click="$emit(\'update:modelValue\', [Number(min) + Number(step ?? 1)])" />'
|
||||
})
|
||||
|
||||
function primePainterState(overrides: Record<string, unknown> = {}) {
|
||||
painterHolder.state = { ...createDefaultPainterState(), ...overrides }
|
||||
}
|
||||
|
||||
function renderWidget(initialModel = '') {
|
||||
const value = ref(initialModel)
|
||||
const Harness = defineComponent({
|
||||
components: { WidgetPainter },
|
||||
setup: () => ({ value }),
|
||||
template: '<WidgetPainter v-model="value" node-id="42" />'
|
||||
})
|
||||
return render(Harness, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Button: ButtonStub, Slider: SliderStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WidgetPainter', () => {
|
||||
beforeEach(() => {
|
||||
sizeHolder.width = 0
|
||||
sizeHolder.height = 0
|
||||
painterHolder.state = null
|
||||
})
|
||||
|
||||
describe('Label visibility', () => {
|
||||
const allLabels = [
|
||||
'Tool',
|
||||
'Size',
|
||||
'Color',
|
||||
'Hardness',
|
||||
'Width',
|
||||
'Height',
|
||||
'Background'
|
||||
]
|
||||
|
||||
it('renders every label in wide layout (width >= 350)', () => {
|
||||
sizeHolder.width = 600
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('still renders every label in compact layout (width < 350)', () => {
|
||||
sizeHolder.width = 200
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps labels at the responsive boundary (width = 350)', () => {
|
||||
sizeHolder.width = 350
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
for (const label of allLabels) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image-input branch', () => {
|
||||
it('hides canvas-size and background controls when an image is connected', () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
expect(screen.queryByText('Width')).toBeNull()
|
||||
expect(screen.queryByText('Height')).toBeNull()
|
||||
expect(screen.queryByText('Background')).toBeNull()
|
||||
expect(screen.getByTestId('painter-dimension-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the input image inside the canvas container', () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
const container = screen.getByTestId('painter-canvas-container')
|
||||
expect(within(container).getByRole('img')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tool selection', () => {
|
||||
it('hides brush-only controls when the eraser tool is active', () => {
|
||||
primePainterState({ tool: ref('eraser') })
|
||||
renderWidget()
|
||||
|
||||
expect(screen.queryByText('Color')).toBeNull()
|
||||
expect(screen.queryByText('Hardness')).toBeNull()
|
||||
})
|
||||
|
||||
it('updates the active tool when clicking brush/eraser buttons', async () => {
|
||||
const tool = ref<'brush' | 'eraser'>('brush')
|
||||
primePainterState({ tool })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByText('Eraser'))
|
||||
expect(tool.value).toBe('eraser')
|
||||
|
||||
await user.click(screen.getByText('Brush'))
|
||||
expect(tool.value).toBe('brush')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas events', () => {
|
||||
it('forwards pointerdown/up to the composable on click', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByTestId('painter-canvas'))
|
||||
|
||||
const s = painterHolder.state!
|
||||
expect(s.handlePointerDown).toHaveBeenCalled()
|
||||
expect(s.handlePointerUp).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards pointerenter/leave to the composable on hover', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
const canvas = screen.getByTestId('painter-canvas')
|
||||
|
||||
await user.hover(canvas)
|
||||
await user.unhover(canvas)
|
||||
|
||||
const s = painterHolder.state!
|
||||
expect(s.handlePointerEnter).toHaveBeenCalled()
|
||||
expect(s.handlePointerLeave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes handleInputImageLoad when the input image fires load', async () => {
|
||||
primePainterState({
|
||||
isImageInputConnected: ref(true),
|
||||
inputImageUrl: ref('/img.png')
|
||||
})
|
||||
renderWidget()
|
||||
|
||||
const img = within(
|
||||
screen.getByTestId('painter-canvas-container')
|
||||
).getByRole('img')
|
||||
await fireEvent.load(img)
|
||||
expect(painterHolder.state!.handleInputImageLoad).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Control bindings', () => {
|
||||
it('invokes handleClear when the clear button is clicked', async () => {
|
||||
primePainterState()
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByTestId('painter-clear-button'))
|
||||
expect(painterHolder.state!.handleClear).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates brushSize via the size slider', async () => {
|
||||
const brushSize = ref(20)
|
||||
primePainterState({ brushSize })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const slider = within(screen.getByTestId('painter-size-row')).getByTestId(
|
||||
'slider-stub'
|
||||
)
|
||||
await user.click(slider)
|
||||
expect(brushSize.value).toBe(2) // min=1, step=1 -> emits 2
|
||||
})
|
||||
|
||||
it('updates brushColor via the color picker', async () => {
|
||||
const brushColor = ref('#000000')
|
||||
primePainterState({ brushColor })
|
||||
renderWidget()
|
||||
|
||||
const colorInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('#000000')
|
||||
// <input type="color"> has no userEvent equivalent — fire input directly
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.input(colorInput, { target: { value: '#ff0000' } })
|
||||
expect(brushColor.value.toLowerCase()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('updates brushOpacity via the percent input', async () => {
|
||||
const brushOpacity = ref(1)
|
||||
primePainterState({ brushOpacity })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const percentInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('100')
|
||||
await user.clear(percentInput)
|
||||
await user.type(percentInput, '50')
|
||||
await user.tab() // blur to trigger @change
|
||||
expect(brushOpacity.value).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
it('clamps opacity input to the 0-100 range', async () => {
|
||||
const brushOpacity = ref(1)
|
||||
primePainterState({ brushOpacity })
|
||||
renderWidget()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const percentInput = within(
|
||||
screen.getByTestId('painter-color-row')
|
||||
).getByDisplayValue('100')
|
||||
await user.clear(percentInput)
|
||||
await user.type(percentInput, '999')
|
||||
await user.tab()
|
||||
expect(brushOpacity.value).toBe(1) // clamped to 100% -> 1.0
|
||||
})
|
||||
|
||||
it('updates background color via the bg color input', async () => {
|
||||
const backgroundColor = ref('#ffffff')
|
||||
primePainterState({ backgroundColor })
|
||||
renderWidget()
|
||||
|
||||
const bgInput = within(
|
||||
screen.getByTestId('painter-bg-color-row')
|
||||
).getByDisplayValue('#ffffff')
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.input(bgInput, { target: { value: '#00ff00' } })
|
||||
expect(backgroundColor.value.toLowerCase()).toBe('#00ff00')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
/>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
data-testid="painter-canvas"
|
||||
class="absolute inset-0 size-full cursor-none touch-none"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@@ -58,7 +59,6 @@
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.tool') }}
|
||||
@@ -99,7 +99,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.size') }}
|
||||
@@ -126,7 +125,6 @@
|
||||
|
||||
<template v-if="tool === PAINTER_TOOLS.BRUSH">
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.color') }}
|
||||
@@ -170,7 +168,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.hardness') }}
|
||||
@@ -199,7 +196,6 @@
|
||||
|
||||
<template v-if="!isImageInputConnected">
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.width') }}
|
||||
@@ -222,7 +218,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.height') }}
|
||||
@@ -245,7 +240,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.background') }}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
@@ -44,24 +44,24 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function isSectionCollapsed(nodeId: string): boolean {
|
||||
function isSectionCollapsed(nodeId: NodeId): boolean {
|
||||
// Defaults to collapsed when not explicitly set by the user
|
||||
return collapseMap[nodeId] ?? true
|
||||
}
|
||||
|
||||
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
|
||||
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
|
||||
collapseMap[nodeId] = collapsed
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return searchedWidgetsSectionDataList.value.every(({ node }) =>
|
||||
isSectionCollapsed(String(node.id))
|
||||
isSectionCollapsed(node.id)
|
||||
)
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const { node } of widgetsSectionDataList.value) {
|
||||
setSectionCollapsed(String(node.id), collapse)
|
||||
setSectionCollapsed(node.id, collapse)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -101,7 +101,7 @@ async function searcher(query: string) {
|
||||
:key="node.id"
|
||||
:node
|
||||
:widgets
|
||||
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
|
||||
:collapse="isSectionCollapsed(node.id) && !isSearching"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
@@ -109,7 +109,7 @@ async function searcher(query: string) {
|
||||
"
|
||||
show-locate-button
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="setSectionCollapsed(String(node.id), $event)"
|
||||
@update:collapse="setSectionCollapsed(node.id, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -68,19 +68,19 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function isSectionCollapsed(nodeId: string): boolean {
|
||||
function isSectionCollapsed(nodeId: NodeId): boolean {
|
||||
// When not explicitly set, sections are collapsed if multiple nodes are selected
|
||||
return collapseMap[nodeId] ?? isMultipleNodesSelected.value
|
||||
}
|
||||
|
||||
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
|
||||
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
|
||||
collapseMap[nodeId] = collapsed
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
const normalAllCollapsed = searchedWidgetsSectionDataList.value.every(
|
||||
({ node }) => isSectionCollapsed(String(node.id))
|
||||
({ node }) => isSectionCollapsed(node.id)
|
||||
)
|
||||
const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0
|
||||
return hasAdvanced
|
||||
@@ -89,7 +89,7 @@ const isAllCollapsed = computed({
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const { node } of widgetsSectionDataList.value) {
|
||||
setSectionCollapsed(String(node.id), collapse)
|
||||
setSectionCollapsed(node.id, collapse)
|
||||
}
|
||||
advancedCollapsed.value = collapse
|
||||
}
|
||||
@@ -154,7 +154,7 @@ const advancedLabel = computed(() => {
|
||||
:node
|
||||
:label
|
||||
:widgets
|
||||
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
|
||||
:collapse="isSectionCollapsed(node.id) && !isSearching"
|
||||
:show-locate-button="isMultipleNodesSelected"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
@@ -162,7 +162,7 @@ const advancedLabel = computed(() => {
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="setSectionCollapsed(String(node.id), $event)"
|
||||
@update:collapse="setSectionCollapsed(node.id, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
|
||||
@@ -21,13 +21,19 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMouseInElement: () => ({
|
||||
elementX: ref(50),
|
||||
elementWidth: ref(100),
|
||||
isOutside: ref(false)
|
||||
})
|
||||
}))
|
||||
const mockRect = (el: HTMLElement, width: number) => {
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: 100,
|
||||
width,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
describe('CompareSliderThumbnail', () => {
|
||||
const renderThumbnail = (props = {}) => {
|
||||
@@ -74,4 +80,44 @@ describe('CompareSliderThumbnail', () => {
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('50%')
|
||||
})
|
||||
|
||||
it('updates slider position on mousemove', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 200)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.pointer({ target: container, coords: { clientX: 50 } })
|
||||
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('25%')
|
||||
})
|
||||
|
||||
it('clamps slider position to [0, 100] when pointer overshoots', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 200)
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.pointer({ target: container, coords: { clientX: -10 } })
|
||||
let divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('0%')
|
||||
|
||||
await user.pointer({ target: container, coords: { clientX: 250 } })
|
||||
divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('100%')
|
||||
})
|
||||
|
||||
it('ignores mousemove when container has zero width', async () => {
|
||||
renderThumbnail()
|
||||
const container = screen.getByTestId('compare-slider-container')
|
||||
mockRect(container, 0)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.pointer({ target: container, coords: { clientX: 50 } })
|
||||
|
||||
const divider = screen.getByTestId('compare-slider-divider')
|
||||
expect(divider.style.left).toBe('50%')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
"
|
||||
/>
|
||||
<div ref="containerRef" class="absolute inset-0">
|
||||
<div
|
||||
data-testid="compare-slider-container"
|
||||
class="absolute inset-0"
|
||||
@mousemove="updateSliderPosition"
|
||||
>
|
||||
<LazyImage
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
@@ -34,8 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
@@ -57,18 +60,20 @@ const isVideoType =
|
||||
false
|
||||
|
||||
const sliderPosition = ref(SLIDER_START_POSITION)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
|
||||
|
||||
// Update slider position based on mouse position when hovered
|
||||
watch(
|
||||
[() => isHovered, elementX, elementWidth, isOutside],
|
||||
([isHovered, x, width, outside]) => {
|
||||
if (!isHovered) return
|
||||
if (!outside) {
|
||||
sliderPosition.value = (x / width) * 100
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* Update slider position from a local mousemove. Scoped to currentTarget so
|
||||
* only the hovered card reads its rect — unlike useMouseInElement which
|
||||
* attaches a global mousemove listener and fires for every mounted instance.
|
||||
*/
|
||||
function updateSliderPosition(event: MouseEvent) {
|
||||
const el = event.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width === 0) return
|
||||
// Clamp to [0, 100] — subpixel rounding or stale rects on hover-in can
|
||||
// push the raw percentage slightly out of range, which would offset the
|
||||
// divider past the container or invert the overlay's clipPath.
|
||||
const raw = ((event.clientX - rect.left) / rect.width) * 100
|
||||
sliderPosition.value = Math.max(0, Math.min(100, raw))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -252,6 +252,20 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(screen.getByText('Log Out')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('credits help icon (FE-617)', () => {
|
||||
it('renders the credits help icon as an interactive button with the unified-credits tooltip as its accessible name', () => {
|
||||
renderComponent()
|
||||
|
||||
const helpButton = screen.getByTestId('credits-info-button')
|
||||
expect(helpButton).toBeInTheDocument()
|
||||
expect(helpButton.tagName).toBe('BUTTON')
|
||||
expect(helpButton).toHaveAttribute(
|
||||
'aria-label',
|
||||
enMessages.credits.unified.tooltip
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings item is clicked', async () => {
|
||||
const { user, onClose } = renderComponent()
|
||||
|
||||
|
||||
@@ -41,10 +41,16 @@
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
}}</span>
|
||||
<i
|
||||
<Button
|
||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||
class="mr-auto icon-[lucide--circle-help] cursor-help text-base text-muted-foreground"
|
||||
/>
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
class="mr-auto"
|
||||
:aria-label="$t('credits.unified.tooltip')"
|
||||
data-testid="credits-info-button"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<div class="relative">
|
||||
<span
|
||||
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
|
||||
>
|
||||
|
||||
186
src/components/topbar/WorkflowTabs.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import WorkflowTabs from './WorkflowTabs.vue'
|
||||
|
||||
const distribution = vi.hoisted(() => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: { value: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
isOverflowing: { value: false },
|
||||
disposed: { value: false },
|
||||
checkOverflow: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
openWorkflow: vi.fn(),
|
||||
closeWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () =>
|
||||
reactive({
|
||||
openWorkflows: [],
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ shiftDown: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mouseDownUtil', () => ({
|
||||
whileMouseDown: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowOverflowMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'WorkflowOverflowMenuStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowTab.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'WorkflowTabStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./CurrentUserButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'CurrentUserButtonStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./LoginButton.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'LoginButtonStub',
|
||||
render: () => h('div')
|
||||
})
|
||||
}))
|
||||
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
const result = render(WorkflowTabs, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
tooltip: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('WorkflowTabs feedback button', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
distribution.isCloud = false
|
||||
distribution.isDesktop = false
|
||||
distribution.isNightly = false
|
||||
tabBarLayout.value = 'Default'
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with topbar source on Cloud', async () => {
|
||||
distribution.isCloud = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=topbar',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the Typeform survey tagged with topbar source on Nightly', async () => {
|
||||
distribution.isNightly = true
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Feedback' }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=topbar',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render the feedback button on non-Cloud/non-Nightly builds', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the feedback button when the legacy tab bar is active', () => {
|
||||
distribution.isCloud = true
|
||||
tabBarLayout.value = 'Legacy'
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Feedback' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -119,7 +119,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -152,9 +152,12 @@ const isIntegratedTabBar = computed(
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
function openFeedback() {
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('topbar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
195
src/composables/auth/useAuthActions.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
|
||||
|
||||
const mockAuthStore = vi.hoisted(() => ({
|
||||
logout: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
modifiedWorkflows: [] as ModifiedWorkflow[]
|
||||
}))
|
||||
|
||||
const mockWorkflowService = vi.hoisted(() => ({
|
||||
saveWorkflow: vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
const mockDialogService = vi.hoisted(() => ({
|
||||
confirm: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, values?: { workflow?: string }) =>
|
||||
values?.workflow ? `${key}:${values.workflow}` : key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => mockToastStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => mockWorkflowStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: vi.fn(() => mockWorkflowService)
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => mockDialogService)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => mockAuthStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isFreeTier: { value: true },
|
||||
type: { value: 'free' }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
|
||||
action: (...args: TArgs) => Promise<TReturn> | TReturn
|
||||
) => action,
|
||||
toastErrorHandler: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
function makeWorkflow(path: string): ModifiedWorkflow {
|
||||
return { path, isModified: true } satisfies ModifiedWorkflow
|
||||
}
|
||||
|
||||
describe('useAuthActions.logout', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.modifiedWorkflows = []
|
||||
})
|
||||
|
||||
it('logs out without prompting when no workflows are modified', async () => {
|
||||
const { logout } = useAuthActions()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(mockDialogService.confirm).not.toHaveBeenCalled()
|
||||
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels sign-out when the dialog is dismissed (null)', async () => {
|
||||
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
|
||||
mockDialogService.confirm.mockResolvedValueOnce(null)
|
||||
const { logout } = useAuthActions()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockAuthStore.logout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('signs out without saving when the user picks "Sign out anyway" (false)', async () => {
|
||||
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
|
||||
mockDialogService.confirm.mockResolvedValueOnce(false)
|
||||
const { logout } = useAuthActions()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(mockDialogService.confirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockWorkflowService.saveWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels sign-out when saving a workflow is cancelled', async () => {
|
||||
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
|
||||
mockDialogService.confirm.mockResolvedValueOnce(true)
|
||||
mockWorkflowService.saveWorkflow.mockResolvedValueOnce(false)
|
||||
const { logout } = useAuthActions()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
|
||||
expect(mockAuthStore.logout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not log out if a workflow save fails', async () => {
|
||||
mockWorkflowStore.modifiedWorkflows = [
|
||||
makeWorkflow('a.json'),
|
||||
makeWorkflow('b.json')
|
||||
]
|
||||
mockDialogService.confirm.mockResolvedValueOnce(true)
|
||||
mockWorkflowService.saveWorkflow.mockRejectedValueOnce(
|
||||
new Error('disk full')
|
||||
)
|
||||
const { logout } = useAuthActions()
|
||||
|
||||
await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json')
|
||||
|
||||
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
|
||||
expect(mockAuthStore.logout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves every modified workflow before signing out when user picks Save (true)', async () => {
|
||||
const workflows = [makeWorkflow('a.json'), makeWorkflow('b.json')]
|
||||
mockWorkflowStore.modifiedWorkflows = workflows
|
||||
mockDialogService.confirm.mockResolvedValueOnce(true)
|
||||
const { logout } = useAuthActions()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(2)
|
||||
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
workflows[0]
|
||||
)
|
||||
expect(mockWorkflowService.saveWorkflow).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
workflows[1]
|
||||
)
|
||||
expect(mockAuthStore.logout).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1]
|
||||
).toBeLessThan(mockAuthStore.logout.mock.invocationCallOrder[0])
|
||||
expect(
|
||||
mockWorkflowService.saveWorkflow.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mockWorkflowService.saveWorkflow.mock.invocationCallOrder[1])
|
||||
})
|
||||
|
||||
it('passes denyLabel "Sign out anyway" to the dialog', async () => {
|
||||
mockWorkflowStore.modifiedWorkflows = [makeWorkflow('a.json')]
|
||||
mockDialogService.confirm.mockResolvedValueOnce(null)
|
||||
const { logout } = useAuthActions()
|
||||
|
||||
await logout()
|
||||
|
||||
expect(mockDialogService.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'dirtyClose',
|
||||
title: 'auth.signOut.unsavedChangesTitle',
|
||||
message: 'auth.signOut.unsavedChangesMessage',
|
||||
denyLabel: 'auth.signOut.signOutAnyway'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -53,14 +54,30 @@ export const useAuthActions = () => {
|
||||
|
||||
const logout = wrapWithErrorHandlingAsync(async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
if (workflowStore.modifiedWorkflows.length > 0) {
|
||||
const modifiedWorkflows = workflowStore.modifiedWorkflows
|
||||
if (modifiedWorkflows.length > 0) {
|
||||
const dialogService = useDialogService()
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('auth.signOut.unsavedChangesTitle'),
|
||||
message: t('auth.signOut.unsavedChangesMessage'),
|
||||
type: 'dirtyClose'
|
||||
type: 'dirtyClose',
|
||||
denyLabel: t('auth.signOut.signOutAnyway')
|
||||
})
|
||||
if (!confirmed) return
|
||||
if (confirmed === null) return
|
||||
|
||||
if (confirmed === true) {
|
||||
const workflowService = useWorkflowService()
|
||||
for (const workflow of modifiedWorkflows) {
|
||||
try {
|
||||
const saved = await workflowService.saveWorkflow(workflow)
|
||||
if (!saved) return
|
||||
} catch {
|
||||
throw new Error(
|
||||
t('auth.signOut.saveFailed', { workflow: workflow.path })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await authStore.logout()
|
||||
|
||||
@@ -543,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
@@ -686,7 +686,7 @@ describe('realtime verification staleness guards', () => {
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import {
|
||||
scanNodeMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
verifyMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
@@ -209,8 +209,8 @@ function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (confirmedMedia.length) {
|
||||
useMissingMediaStore().addMissingMedia(confirmedMedia)
|
||||
}
|
||||
// Cloud media scans always return isMissing: undefined pending
|
||||
// verification against the input-assets list.
|
||||
// Cloud media scans return pending for asset verification. OSS scans only
|
||||
// return pending for generated output/temp media.
|
||||
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
|
||||
if (pendingMedia.length) {
|
||||
void verifyAndAddPendingMedia(pendingMedia)
|
||||
@@ -282,7 +282,7 @@ async function verifyAndAddPendingMedia(
|
||||
): Promise<void> {
|
||||
const rootGraphAtScan = app.rootGraph
|
||||
try {
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
await verifyMediaCandidates(pending, { isCloud })
|
||||
if (app.rootGraph !== rootGraphAtScan) return
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
|
||||
@@ -73,12 +73,14 @@ export const useNodeDragAndDrop = <T>(
|
||||
return true
|
||||
}
|
||||
|
||||
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
|
||||
const baseUri = e?.dataTransfer?.getData('text/uri-list') ?? ''
|
||||
const uri = URL.parse(baseUri, location.href)
|
||||
if (!uri || uri.origin !== location.origin) return false
|
||||
|
||||
try {
|
||||
const resp = await fetch(uri)
|
||||
const fileName = uri?.searchParams?.get('filename')
|
||||
const fileName =
|
||||
uri?.searchParams?.get('filename') ?? baseUri.split('/').at(-1)
|
||||
if (!fileName || !resp.ok) return false
|
||||
|
||||
const blob = await resp.blob()
|
||||
|
||||
83
src/extensions/core/cloudFeedbackTopbarButton.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
|
||||
|
||||
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
|
||||
const registerExtension = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
},
|
||||
get isNightly() {
|
||||
return distribution.isNightly
|
||||
}
|
||||
}))
|
||||
|
||||
describe('cloudFeedbackTopbarButton', () => {
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
registerExtension.mockReset()
|
||||
distribution.isCloud = false
|
||||
distribution.isNightly = false
|
||||
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
function getRegisteredButtons(): ActionBarButton[] {
|
||||
expect(registerExtension).toHaveBeenCalledTimes(1)
|
||||
const extension = registerExtension.mock.calls[0]?.[0] as {
|
||||
actionBarButtons: ActionBarButton[]
|
||||
}
|
||||
return extension.actionBarButtons
|
||||
}
|
||||
|
||||
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
|
||||
tabBarLayout.value = 'Legacy'
|
||||
distribution.isCloud = true
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
const buttons = getRegisteredButtons()
|
||||
expect(buttons).toHaveLength(1)
|
||||
buttons[0].onClick?.()
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1)
|
||||
const [url, target, features] = openSpy.mock.calls[0]
|
||||
expect(url).toBe(
|
||||
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
|
||||
)
|
||||
expect(target).toBe('_blank')
|
||||
expect(features).toBe('noopener,noreferrer')
|
||||
})
|
||||
|
||||
it('only registers the action bar button when the tab bar is Legacy', async () => {
|
||||
tabBarLayout.value = 'Default'
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
expect(getRegisteredButtons()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,20 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const TYPEFORM_SURVEY_URL = 'https://form.typeform.com/to/q7azbWPi'
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
window.open(TYPEFORM_SURVEY_URL, '_blank', 'noopener,noreferrer')
|
||||
window.open(
|
||||
buildFeedbackTypeformUrl('action-bar'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
487
src/extensions/core/load3d.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const {
|
||||
registerExtensionMock,
|
||||
waitForLoad3dMock,
|
||||
configureMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension: registerExtensionMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({
|
||||
getLoad3d: getLoad3dMock,
|
||||
handleViewerClose: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock }),
|
||||
nodeToLoad3dMap: new Map()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configure = configureMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
|
||||
createExportMenuItems: vi.fn(() => [{ content: 'Export' }])
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn((p: string) => ['', p]),
|
||||
getResourceURL: vi.fn(() => '/view'),
|
||||
uploadFile: vi.fn(),
|
||||
uploadMultipleFiles: vi.fn(),
|
||||
uploadTempImage: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/constants', () => ({
|
||||
SUPPORTED_EXTENSIONS_ACCEPT: '.glb,.gltf'
|
||||
}))
|
||||
|
||||
vi.mock('@/components/load3d/Load3D.vue', () => ({ default: {} }))
|
||||
vi.mock('@/components/load3d/Load3dViewerContent.vue', () => ({ default: {} }))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn(),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { apiURL: (p: string) => p }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: {} } },
|
||||
ComfyApp: { copyToClipspace: vi.fn(), clipspace_return_node: null }
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: toastAddAlertMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLoad3dNode: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: { ContextMenu: vi.fn() }
|
||||
}))
|
||||
|
||||
type ExtCreated = ComfyExtension & {
|
||||
nodeCreated: (node: LGraphNode) => Promise<void>
|
||||
beforeRegisterNodeDef: (
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) => Promise<void>
|
||||
getNodeMenuItems: (node: LGraphNode) => unknown[]
|
||||
}
|
||||
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
serializeValue?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
function makePreview3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3D' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'model_file', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLoad3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
|
||||
size: [300, 600],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
setCameraFromMatrices: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
function makeLoad3dMock(): FakeLoad3d {
|
||||
return {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
setCameraFromMatrices: vi.fn(),
|
||||
setBackgroundImage: vi.fn(),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
}
|
||||
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3D.beforeRegisterNodeDef', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('rewrites the image input spec for Preview3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const nodeData = {
|
||||
name: 'Preview3D',
|
||||
input: { required: { image: ['STRING', {}] } }
|
||||
} as unknown as ComfyNodeDef
|
||||
|
||||
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(nodeData.input!.required!.image).toEqual(['PREVIEW_3D'])
|
||||
})
|
||||
|
||||
it('leaves non-Preview3D node defs unchanged', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const nodeData = {
|
||||
name: 'Load3D',
|
||||
input: { required: { image: ['STRING', {}] } }
|
||||
} as unknown as ComfyNodeDef
|
||||
|
||||
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(nodeData.input!.required!.image).toEqual(['STRING', {}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3D.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Preview3D', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not configure on creation when no Last Time Model File is persisted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores via configure with persisted cameraState when Last Time Model File is set', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const cameraState = { position: [1, 2, 3] }
|
||||
const node = makePreview3DNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'prev/model.glb',
|
||||
'Camera Config': { cameraType: 'perspective', state: cameraState }
|
||||
}
|
||||
})
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'output',
|
||||
modelWidget: expect.objectContaining({ value: 'prev/model.glb' }),
|
||||
cameraState,
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
it('persists Last Time Model File and normalizes backslashes after onExecuted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
|
||||
expect(configureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
loadFolder: 'output',
|
||||
silentOnNotFound: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards bgImagePath to load3d.setBackgroundImage on execute', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', undefined, 'bg.png'] })
|
||||
|
||||
expect(load3d.setBackgroundImage).toHaveBeenCalledWith('bg.png')
|
||||
})
|
||||
|
||||
it('applies camera matrices when load3d generation is unchanged', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const extrinsics = [
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
[0, 0, 0, 1]
|
||||
]
|
||||
const intrinsics = [
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, 1]
|
||||
]
|
||||
|
||||
const node = makePreview3DNode()
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, undefined, extrinsics, intrinsics]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraFromMatrices).toHaveBeenCalledWith(
|
||||
extrinsics,
|
||||
intrinsics
|
||||
)
|
||||
})
|
||||
|
||||
it('skips camera matrix application when load3d generation changes before whenLoadIdle resolves', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
let resolveIdle: () => void = () => {}
|
||||
load3d.whenLoadIdle = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveIdle = resolve
|
||||
})
|
||||
)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const node = makePreview3DNode()
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, undefined, [[1]], [[1]]]
|
||||
})
|
||||
|
||||
load3d.currentLoadGeneration = 6
|
||||
resolveIdle()
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraFromMatrices).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error toast when onExecuted has no file path', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Load3D', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('configures with the input folder and width/height widgets', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: 'model.glb' },
|
||||
{ name: 'width', value: 1024 },
|
||||
{ name: 'height', value: 768 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'input',
|
||||
modelWidget: widgets[0],
|
||||
cameraState: undefined,
|
||||
width: widgets[1],
|
||||
height: widgets[2]
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches a serializeValue function to the scene widget', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(typeof widgets[3].serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('skips configure when required widgets are missing', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({
|
||||
widgets: [{ name: 'model_file', value: '' }]
|
||||
})
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('Comfy.Load3D returns [] for non-Load3D nodes', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(load3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('Comfy.Preview3D returns [] for non-Preview3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when no load3d instance exists for the node', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue(null)
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns export menu items for non-splat 3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([{ content: 'Export' }])
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
CameraState
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import {
|
||||
LOAD3D_NONE_MODEL,
|
||||
SUPPORTED_EXTENSIONS_ACCEPT
|
||||
} from '@/extensions/core/load3d/constants'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -290,13 +293,9 @@ useExtensionService().registerExtension({
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
load3d.clearModel()
|
||||
})
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
modelWidget.value = ''
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,17 +6,22 @@ import Load3DConfiguration, {
|
||||
} from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoConfig,
|
||||
ModelConfig
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const { settingsGetMock } = vi.hoisted(() => ({
|
||||
settingsGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
useSettingStore: () => ({ get: settingsGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -43,13 +48,22 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
type WithPrivate = { loadModelConfig(): ModelConfig }
|
||||
type WithPrivate = {
|
||||
loadModelConfig(): ModelConfig
|
||||
loadSceneConfig(): SceneConfig
|
||||
loadCameraConfig(): CameraConfig
|
||||
loadLightConfig(): LightConfig
|
||||
}
|
||||
|
||||
function createConfig(properties?: Dictionary<NodeProperty | undefined>) {
|
||||
const load3d = {} as Load3d
|
||||
return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate
|
||||
}
|
||||
|
||||
function stubSettings(values: Record<string, unknown>) {
|
||||
settingsGetMock.mockImplementation((key: string) => values[key])
|
||||
}
|
||||
|
||||
const defaultGizmo: GizmoConfig = {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
@@ -58,6 +72,13 @@ const defaultGizmo: GizmoConfig = {
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
|
||||
const hdriDefaults = {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
} as const
|
||||
|
||||
describe('Load3DConfiguration.loadModelConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
@@ -342,3 +363,322 @@ describe('parseAnnotatedFilename', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.loadSceneConfig', () => {
|
||||
beforeEach(() => {
|
||||
settingsGetMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the persisted Scene Config when present, ignoring settings', () => {
|
||||
const stored: SceneConfig = {
|
||||
showGrid: false,
|
||||
backgroundColor: '#123456',
|
||||
backgroundImage: 'bg.png'
|
||||
}
|
||||
const properties = { 'Scene Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
stubSettings({
|
||||
'Comfy.Load3D.ShowGrid': true,
|
||||
'Comfy.Load3D.BackgroundColor': 'aaaaaa'
|
||||
})
|
||||
|
||||
expect(createConfig(properties).loadSceneConfig()).toEqual(stored)
|
||||
expect(settingsGetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to settings and prepends # to the background color', () => {
|
||||
stubSettings({
|
||||
'Comfy.Load3D.ShowGrid': false,
|
||||
'Comfy.Load3D.BackgroundColor': 'abcdef'
|
||||
})
|
||||
|
||||
expect(createConfig().loadSceneConfig()).toEqual({
|
||||
showGrid: false,
|
||||
backgroundColor: '#abcdef',
|
||||
backgroundImage: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.loadCameraConfig', () => {
|
||||
beforeEach(() => {
|
||||
settingsGetMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the persisted Camera Config when present', () => {
|
||||
const stored: CameraConfig = {
|
||||
cameraType: 'orthographic',
|
||||
fov: 50
|
||||
}
|
||||
const properties = { 'Camera Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
stubSettings({ 'Comfy.Load3D.CameraType': 'perspective' })
|
||||
|
||||
expect(createConfig(properties).loadCameraConfig()).toEqual(stored)
|
||||
expect(settingsGetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to settings and a default fov of 35', () => {
|
||||
stubSettings({ 'Comfy.Load3D.CameraType': 'perspective' })
|
||||
|
||||
expect(createConfig().loadCameraConfig()).toEqual({
|
||||
cameraType: 'perspective',
|
||||
fov: 35
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.loadLightConfig', () => {
|
||||
beforeEach(() => {
|
||||
settingsGetMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('falls back to settings with default hdri when nothing is persisted', () => {
|
||||
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
|
||||
|
||||
expect(createConfig().loadLightConfig()).toEqual({
|
||||
intensity: 4,
|
||||
hdri: hdriDefaults
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the persisted intensity over the setting when present', () => {
|
||||
const stored: Partial<LightConfig> = { intensity: 7 }
|
||||
const properties = { 'Light Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
|
||||
|
||||
expect(createConfig(properties).loadLightConfig()).toEqual({
|
||||
intensity: 7,
|
||||
hdri: hdriDefaults
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the setting intensity when persisted intensity is missing', () => {
|
||||
const properties = { 'Light Config': {} } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
|
||||
|
||||
expect(createConfig(properties).loadLightConfig()).toEqual({
|
||||
intensity: 4,
|
||||
hdri: hdriDefaults
|
||||
})
|
||||
})
|
||||
|
||||
it('merges persisted hdri partial over hdri defaults', () => {
|
||||
const stored: Partial<LightConfig> = {
|
||||
intensity: 2,
|
||||
hdri: { hdriPath: 'env.hdr', enabled: true } as LightConfig['hdri']
|
||||
}
|
||||
const properties = { 'Light Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
expect(createConfig(properties).loadLightConfig()).toEqual({
|
||||
intensity: 2,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: 'env.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.configure forwards persisted + settings to load3d', () => {
|
||||
let load3d: Load3d
|
||||
|
||||
function makeLoad3dMock(): Load3d {
|
||||
return {
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
setUpDirection: vi.fn(),
|
||||
setMaterialMode: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIEnabled: vi.fn(),
|
||||
emitModelReady: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settingsGetMock.mockReset()
|
||||
load3d = makeLoad3dMock()
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses settings defaults when no Scene/Camera/Light Config is persisted', async () => {
|
||||
stubSettings({
|
||||
'Comfy.Load3D.ShowGrid': true,
|
||||
'Comfy.Load3D.BackgroundColor': '282828',
|
||||
'Comfy.Load3D.CameraType': 'orthographic',
|
||||
'Comfy.Load3D.LightIntensity': 6
|
||||
})
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output'
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.toggleGrid).toHaveBeenCalledWith(true)
|
||||
expect(load3d.setBackgroundColor).toHaveBeenCalledWith('#282828')
|
||||
expect(load3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(load3d.setFOV).toHaveBeenCalledWith(35)
|
||||
expect(load3d.setLightIntensity).toHaveBeenCalledWith(6)
|
||||
})
|
||||
|
||||
it('prefers persisted Scene/Camera/Light Config over settings', async () => {
|
||||
const properties = {
|
||||
'Scene Config': {
|
||||
showGrid: false,
|
||||
backgroundColor: '#101010',
|
||||
backgroundImage: ''
|
||||
},
|
||||
'Camera Config': { cameraType: 'perspective', fov: 60 },
|
||||
'Light Config': { intensity: 9 }
|
||||
} as unknown as Dictionary<NodeProperty | undefined>
|
||||
stubSettings({
|
||||
'Comfy.Load3D.ShowGrid': true,
|
||||
'Comfy.Load3D.BackgroundColor': '282828',
|
||||
'Comfy.Load3D.CameraType': 'orthographic',
|
||||
'Comfy.Load3D.LightIntensity': 1
|
||||
})
|
||||
|
||||
const config = new Load3DConfiguration(load3d, properties)
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output'
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.toggleGrid).toHaveBeenCalledWith(false)
|
||||
expect(load3d.setBackgroundColor).toHaveBeenCalledWith('#101010')
|
||||
expect(load3d.toggleCamera).toHaveBeenCalledWith('perspective')
|
||||
expect(load3d.setFOV).toHaveBeenCalledWith(60)
|
||||
expect(load3d.setLightIntensity).toHaveBeenCalledWith(9)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration "none" model handling', () => {
|
||||
let load3d: Load3d
|
||||
let loadModelSpy: ReturnType<typeof vi.fn>
|
||||
let clearModelSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
function makeLoad3dMock(): Load3d {
|
||||
loadModelSpy = vi.fn().mockResolvedValue(undefined)
|
||||
clearModelSpy = vi.fn()
|
||||
return {
|
||||
loadModel: loadModelSpy,
|
||||
clearModel: clearModelSpy,
|
||||
setUpDirection: vi.fn(),
|
||||
setMaterialMode: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIEnabled: vi.fn(),
|
||||
emitModelReady: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
load3d = makeLoad3dMock()
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not load or clear a model when the initial widget value is "none"', async () => {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
config.configure({
|
||||
modelWidget: { value: 'none' } as unknown as IBaseWidget,
|
||||
loadFolder: 'input'
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(loadModelSpy).not.toHaveBeenCalled()
|
||||
expect(clearModelSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the model (and skips loadModel) when the widget value changes to "none"', async () => {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
const widget = { value: 'model.glb' } as unknown as IBaseWidget
|
||||
config.configure({ modelWidget: widget, loadFolder: 'input' })
|
||||
await flush()
|
||||
|
||||
loadModelSpy.mockClear()
|
||||
clearModelSpy.mockClear()
|
||||
|
||||
widget.value = 'none'
|
||||
await flush()
|
||||
|
||||
expect(clearModelSpy).toHaveBeenCalledTimes(1)
|
||||
expect(loadModelSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads a model when the widget value transitions from "none" to a real path', async () => {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
const widget = { value: 'none' } as unknown as IBaseWidget
|
||||
config.configure({ modelWidget: widget, loadFolder: 'input' })
|
||||
await flush()
|
||||
|
||||
expect(loadModelSpy).not.toHaveBeenCalled()
|
||||
|
||||
widget.value = 'model.glb'
|
||||
await flush()
|
||||
|
||||
expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
|
||||
silentOnNotFound: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LOAD3D_NONE_MODEL } from '@/extensions/core/load3d/constants'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
@@ -109,7 +110,7 @@ class Load3DConfiguration {
|
||||
cameraState,
|
||||
silentOnNotFound
|
||||
)
|
||||
if (modelWidget.value) {
|
||||
if (modelWidget.value && modelWidget.value !== LOAD3D_NONE_MODEL) {
|
||||
void onModelWidgetUpdate(modelWidget.value)
|
||||
}
|
||||
|
||||
@@ -280,7 +281,10 @@ class Load3DConfiguration {
|
||||
) {
|
||||
let isFirstLoad = true
|
||||
return async (value: string | number | boolean | object) => {
|
||||
if (!value) return
|
||||
if (!value || value === LOAD3D_NONE_MODEL) {
|
||||
this.load3d.clearModel()
|
||||
return
|
||||
}
|
||||
|
||||
const { filename, folder } = parseAnnotatedFilename(
|
||||
value as string,
|
||||
|
||||
@@ -22,3 +22,5 @@ export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
|
||||
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
|
||||
...SUPPORTED_HDRI_EXTENSIONS
|
||||
].join(',')
|
||||
|
||||
export const LOAD3D_NONE_MODEL = 'none'
|
||||
|
||||
197
src/extensions/core/saveMesh.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const { registerExtensionMock, waitForLoad3dMock, configureForSaveMeshMock } =
|
||||
vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
configureForSaveMeshMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension: registerExtensionMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({ getLoad3d: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configureForSaveMesh = configureForSaveMeshMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
|
||||
createExportMenuItems: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/components/load3d/Load3D.vue', () => ({ default: {} }))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn(),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetPreviewUtil', () => ({
|
||||
isAssetPreviewSupported: vi.fn(() => false),
|
||||
persistThumbnail: vi.fn()
|
||||
}))
|
||||
|
||||
type SaveMeshExtension = ComfyExtension & {
|
||||
nodeCreated: (node: LGraphNode) => Promise<void>
|
||||
}
|
||||
|
||||
async function loadSaveMeshExtensionFresh(): Promise<SaveMeshExtension> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/saveMesh')
|
||||
return registerExtensionMock.mock.calls[0][0] as SaveMeshExtension
|
||||
}
|
||||
|
||||
function makeNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
const { comfyClass = 'SaveGLB', properties = {} } = overrides
|
||||
return {
|
||||
constructor: { comfyClass },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: [{ name: 'image', value: '' }],
|
||||
properties
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('saveMesh', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
cb({
|
||||
whenLoadIdle: () => Promise.resolve(),
|
||||
captureThumbnail: vi.fn()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('registers a single Comfy.SaveGLB extension on import', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledOnce()
|
||||
expect(ext.name).toBe('Comfy.SaveGLB')
|
||||
expect(typeof ext.nodeCreated).toBe('function')
|
||||
})
|
||||
|
||||
it('skips nodes whose comfyClass is not SaveGLB', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await ext.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not load a model on creation when no Last Time Model File is persisted', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode()
|
||||
|
||||
await ext.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the persisted model on creation using the persisted folder', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'sub/model.glb',
|
||||
'Last Time Model Folder': 'output'
|
||||
}
|
||||
})
|
||||
|
||||
await ext.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'sub/model.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
expect(node.widgets?.find((w) => w.name === 'image')?.value).toBe(
|
||||
'sub/model.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the load folder to output when only the file path is persisted', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode({
|
||||
properties: { 'Last Time Model File': 'model.glb' }
|
||||
})
|
||||
|
||||
await ext.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'model.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('persists Last Time Model File and Folder after onExecuted', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode()
|
||||
|
||||
await ext.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }]
|
||||
})
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/mesh.glb')
|
||||
expect(node.properties['Last Time Model Folder']).toBe('output')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'sub/mesh.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not persist anything when onExecuted has no 3d output', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode()
|
||||
|
||||
await ext.nodeCreated(node)
|
||||
node.onExecuted!({})
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBeUndefined()
|
||||
expect(node.properties['Last Time Model Folder']).toBeUndefined()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the persisted state from a prior run when the node is recreated', async () => {
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
|
||||
const firstNode = makeNode()
|
||||
await ext.nodeCreated(firstNode)
|
||||
firstNode.onExecuted!({
|
||||
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }]
|
||||
})
|
||||
|
||||
configureForSaveMeshMock.mockClear()
|
||||
const recreated = makeNode({ properties: { ...firstNode.properties } })
|
||||
await ext.nodeCreated(recreated)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'sub/mesh.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -81,6 +81,32 @@ useExtensionService().registerExtension({
|
||||
|
||||
await nextTick()
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
if (!load3d) return
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (!modelWidget) return
|
||||
|
||||
const lastTimeModelFile = node.properties['Last Time Model File'] as
|
||||
| string
|
||||
| undefined
|
||||
const lastTimeModelFolder =
|
||||
(node.properties['Last Time Model Folder'] as
|
||||
| 'input'
|
||||
| 'output'
|
||||
| undefined) ?? 'output'
|
||||
|
||||
if (lastTimeModelFile) {
|
||||
modelWidget.value = lastTimeModelFile
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
|
||||
config.configureForSaveMesh(lastTimeModelFolder, lastTimeModelFile, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
node.onExecuted = function (output: SaveMeshOutput) {
|
||||
@@ -103,6 +129,9 @@ useExtensionService().registerExtension({
|
||||
|
||||
const loadFolder = fileInfo.type as 'input' | 'output'
|
||||
|
||||
node.properties['Last Time Model File'] = filePath
|
||||
node.properties['Last Time Model Folder'] = loadFolder
|
||||
|
||||
config.configureForSaveMesh(loadFolder, filePath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
173
src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
static override type = 'TestNode'
|
||||
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'TestNode')
|
||||
}
|
||||
}
|
||||
|
||||
function makeNodeClass(title: string) {
|
||||
class N extends TestNode {
|
||||
static override title = title
|
||||
|
||||
constructor() {
|
||||
super(title)
|
||||
}
|
||||
}
|
||||
return N
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
el.height = 600
|
||||
const ctx = fromPartial<CanvasRenderingContext2D>({
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 })
|
||||
})
|
||||
|
||||
el.getContext = vi.fn().mockReturnValue(ctx)
|
||||
el.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
return new LGraphCanvas(el, graph, { skip_render: true })
|
||||
}
|
||||
|
||||
type MenuEntry = IContextMenuValue<string>
|
||||
|
||||
describe('LGraphCanvas.onMenuAdd category sorting', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
const registeredTypes: string[] = []
|
||||
let originalContextMenu: typeof LiteGraph.ContextMenu
|
||||
const capturedEntries: MenuEntry[][] = []
|
||||
|
||||
beforeEach(() => {
|
||||
graph = new LGraph()
|
||||
canvas = createCanvas(graph)
|
||||
LGraphCanvas.active_canvas = canvas
|
||||
|
||||
capturedEntries.length = 0
|
||||
originalContextMenu = LiteGraph.ContextMenu
|
||||
const MockContextMenu = vi.fn(function (
|
||||
this: unknown,
|
||||
values: MenuEntry[]
|
||||
) {
|
||||
capturedEntries.push(values)
|
||||
}) as unknown as typeof LiteGraph.ContextMenu
|
||||
LiteGraph.ContextMenu = MockContextMenu
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
for (const type of registeredTypes) {
|
||||
delete LiteGraph.registered_node_types[type]
|
||||
}
|
||||
registeredTypes.length = 0
|
||||
})
|
||||
|
||||
function register(type: string, title: string) {
|
||||
LiteGraph.registerNodeType(type, makeNodeClass(title))
|
||||
registeredTypes.push(type)
|
||||
}
|
||||
|
||||
function openTopLevelMenu() {
|
||||
const event = new MouseEvent('contextmenu', { clientX: 10, clientY: 10 })
|
||||
LGraphCanvas.onMenuAdd(undefined, undefined, event)
|
||||
return event
|
||||
}
|
||||
|
||||
function drillInto(label: string, sourceEvent: MouseEvent) {
|
||||
const top = capturedEntries[capturedEntries.length - 1]
|
||||
const entry = top.find((e) => e.content === label)
|
||||
expect(entry, `submenu entry "${label}" should exist`).toBeDefined()
|
||||
expect(entry!.callback).toBeDefined()
|
||||
expect(typeof entry!.value).toBe('string')
|
||||
const callback = entry!.callback!
|
||||
const menuThis = document.createElement('div') as ThisParameterType<
|
||||
typeof callback
|
||||
>
|
||||
void callback.call(menuThis, entry, undefined, sourceEvent, undefined)
|
||||
}
|
||||
|
||||
it('sorts top-level category submenus alphabetically (case-insensitive)', () => {
|
||||
register('zebra/zNode', 'Zebra Node')
|
||||
register('Apple/aNode', 'Apple Node')
|
||||
register('middle/mNode', 'Middle Node')
|
||||
|
||||
openTopLevelMenu()
|
||||
|
||||
const submenuLabels = capturedEntries[0]
|
||||
.filter((e) => e.has_submenu)
|
||||
.map((e) => e.content)
|
||||
const ours = submenuLabels.filter((label) =>
|
||||
['Apple', 'middle', 'zebra'].includes(label ?? '')
|
||||
)
|
||||
expect(ours).toEqual(['Apple', 'middle', 'zebra'])
|
||||
})
|
||||
|
||||
it('uses natural numeric ordering for numbered category names', () => {
|
||||
register('Cat10/n10', 'Item10')
|
||||
register('Cat2/n2', 'Item2')
|
||||
register('Cat1/n1', 'Item1')
|
||||
|
||||
openTopLevelMenu()
|
||||
|
||||
const ours = capturedEntries[0]
|
||||
.filter(
|
||||
(e) =>
|
||||
e.has_submenu && ['Cat1', 'Cat2', 'Cat10'].includes(e.content ?? '')
|
||||
)
|
||||
.map((e) => e.content)
|
||||
expect(ours).toEqual(['Cat1', 'Cat2', 'Cat10'])
|
||||
})
|
||||
|
||||
it('sorts leaf nodes inside a category alphabetically', () => {
|
||||
register('leafsort/Zeta', 'Zeta')
|
||||
register('leafsort/Alpha', 'Alpha')
|
||||
register('leafsort/Mike', 'Mike')
|
||||
|
||||
const event = openTopLevelMenu()
|
||||
drillInto('leafsort', event)
|
||||
|
||||
const leafLabels = capturedEntries[1]
|
||||
.filter((e) => !e.has_submenu)
|
||||
.map((e) => e.content)
|
||||
expect(leafLabels).toEqual(['Alpha', 'Mike', 'Zeta'])
|
||||
})
|
||||
|
||||
it('places category submenus before leaf entries within a category level', () => {
|
||||
register('mixed/leafA', 'A Leaf')
|
||||
register('mixed/leafZ', 'Z Leaf')
|
||||
register('mixed/inner/deep', 'Deep')
|
||||
|
||||
const event = openTopLevelMenu()
|
||||
drillInto('mixed', event)
|
||||
|
||||
const inside = capturedEntries[1]
|
||||
const ours = inside.filter((e) =>
|
||||
['inner', 'A Leaf', 'Z Leaf'].includes(e.content ?? '')
|
||||
)
|
||||
expect(ours[0].content).toBe('inner')
|
||||
expect(ours[0].has_submenu).toBe(true)
|
||||
expect(ours[1].content).toBe('A Leaf')
|
||||
expect(ours[2].content).toBe('Z Leaf')
|
||||
})
|
||||
})
|
||||
@@ -1179,7 +1179,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const categories = LiteGraph.getNodeTypesCategories(
|
||||
canvas.filter || graph.filter
|
||||
).filter((category) => category.startsWith(base_category))
|
||||
const entries: AddNodeMenu[] = []
|
||||
const categoryEntries: AddNodeMenu[] = []
|
||||
|
||||
for (const category of categories) {
|
||||
if (!category) continue
|
||||
@@ -1197,11 +1197,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// in case it has a namespace like "shader::math/rand" it hides the namespace
|
||||
if (name.includes('::')) name = name.split('::', 2)[1]
|
||||
|
||||
const index = entries.findIndex(
|
||||
const index = categoryEntries.findIndex(
|
||||
(entry) => entry.value === category_path
|
||||
)
|
||||
if (index === -1) {
|
||||
entries.push({
|
||||
categoryEntries.push({
|
||||
value: category_path,
|
||||
content: name,
|
||||
has_submenu: true,
|
||||
@@ -1212,11 +1212,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
const compareByContent = (a: AddNodeMenu, b: AddNodeMenu) =>
|
||||
(a.content ?? '').localeCompare(b.content ?? '', undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
categoryEntries.sort(compareByContent)
|
||||
|
||||
const nodes = LiteGraph.getNodeTypesInCategory(
|
||||
base_category.slice(0, -1),
|
||||
canvas.filter || graph.filter
|
||||
)
|
||||
|
||||
const nodeEntries: AddNodeMenu[] = []
|
||||
for (const node of nodes) {
|
||||
if (node.skip_list) continue
|
||||
|
||||
@@ -1246,9 +1254,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(entry)
|
||||
nodeEntries.push(entry)
|
||||
}
|
||||
|
||||
nodeEntries.sort(compareByContent)
|
||||
|
||||
const entries: AddNodeMenu[] = [...categoryEntries, ...nodeEntries]
|
||||
|
||||
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
},
|
||||
"login": {
|
||||
"andText": "و",
|
||||
"backToGithubLogin": "سجّل باستخدام Github بدلاً من ذلك",
|
||||
"backToLogin": "العودة إلى تسجيل الدخول",
|
||||
"backToSocialLogin": "سجّل باستخدام Google أو Github بدلاً من ذلك",
|
||||
"confirmPasswordLabel": "تأكيد كلمة المرور",
|
||||
@@ -292,7 +293,9 @@
|
||||
"title": "إعادة المصادقة مطلوبة"
|
||||
},
|
||||
"signOut": {
|
||||
"saveFailed": "تم إلغاء تسجيل الخروج بسبب فشل حفظ \"{workflow}\".",
|
||||
"signOut": "تسجيل الخروج",
|
||||
"signOutAnyway": "تسجيل الخروج على أي حال",
|
||||
"success": "تم تسجيل الخروج بنجاح",
|
||||
"successDetail": "لقد تم تسجيل خروجك من حسابك.",
|
||||
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد عند تسجيل الخروج. هل ترغب في المتابعة؟",
|
||||
@@ -782,6 +785,7 @@
|
||||
"AUDIO_ENCODER": "مُشَفِّر الصوت",
|
||||
"AUDIO_ENCODER_OUTPUT": "مخرجات مُشَفِّر الصوت",
|
||||
"AUDIO_RECORD": "تسجيل صوتي",
|
||||
"BACKGROUND_REMOVAL": "إزالة الخلفية",
|
||||
"BOOLEAN": "منطقي",
|
||||
"BOUNDING_BOX": "مربع التحديد",
|
||||
"CAMERA_CONTROL": "تحكم الكاميرا",
|
||||
@@ -835,6 +839,7 @@
|
||||
"NOISE": "ضجيج",
|
||||
"OPENAI_CHAT_CONFIG": "إعدادات محادثة أوبن إيه آي",
|
||||
"OPENAI_INPUT_FILES": "ملفات إدخال أوبن إيه آي",
|
||||
"OPTICAL_FLOW": "التدفق البصري",
|
||||
"PHOTOMAKER": "صانع الصور",
|
||||
"PIXVERSE_TEMPLATE": "قالب PixVerse",
|
||||
"POSE_KEYPOINT": "نقطة مفتاحية للوضعية",
|
||||
@@ -2280,15 +2285,13 @@
|
||||
"Vidu": "فيدو",
|
||||
"Wan": "وان",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
"_for_testing": "_للاختبار",
|
||||
"advanced": "متقدم",
|
||||
"animation": "الرسوم المتحركة",
|
||||
"api": "API",
|
||||
"api node": "عقدة API",
|
||||
"attention_experiments": "تجارب الانتباه",
|
||||
"audio": "صوت",
|
||||
"background removal": "إزالة الخلفية",
|
||||
"batch": "دفعة",
|
||||
"camera": "كاميرا",
|
||||
"chroma_radiance": "تألق اللون",
|
||||
"clip": "clip",
|
||||
"color": "لون",
|
||||
@@ -2297,7 +2300,6 @@
|
||||
"cond pair": "زوج شرطي",
|
||||
"cond single": "شرط فردي",
|
||||
"conditioning": "التكييف",
|
||||
"context": "سياق",
|
||||
"controlnet": "كونترول نت",
|
||||
"create": "إنشاء",
|
||||
"custom_sampling": "تجميع مخصص",
|
||||
@@ -2306,6 +2308,7 @@
|
||||
"deprecated": "مهمل",
|
||||
"detection": "الكشف",
|
||||
"edit_models": "تحرير النماذج",
|
||||
"experimental": "تجريبي",
|
||||
"flux": "تدفق",
|
||||
"gligen": "gligen",
|
||||
"guidance": "التوجيه",
|
||||
@@ -2321,7 +2324,6 @@
|
||||
"lotus": "lotus",
|
||||
"ltxv": "ltxv",
|
||||
"mask": "قناع",
|
||||
"math": "رياضيات",
|
||||
"model": "نموذج",
|
||||
"model_merging": "دمج النماذج",
|
||||
"model_patches": "تصحيحات النموذج",
|
||||
@@ -2338,7 +2340,6 @@
|
||||
"save": "حفظ",
|
||||
"schedulers": "الجدولة",
|
||||
"scheduling": "الجدولة",
|
||||
"sd": "sd",
|
||||
"sd3": "sd3",
|
||||
"shader": "shader",
|
||||
"sigmas": "سيجمات",
|
||||
@@ -2346,7 +2347,6 @@
|
||||
"style_model": "نموذج النمط",
|
||||
"supir": "supir",
|
||||
"text": "نص",
|
||||
"textgen": "textgen",
|
||||
"training": "تدريب",
|
||||
"transform": "تحويل",
|
||||
"unet": "unet",
|
||||
@@ -3150,6 +3150,7 @@
|
||||
"deleteFailedTitle": "فشل الحذف",
|
||||
"deleted": "تم حذف سير العمل",
|
||||
"dirtyClose": "تم تعديل الملفات أدناه. هل تريد حفظها قبل الإغلاق؟",
|
||||
"dirtyCloseAnyway": "إغلاق على أي حال",
|
||||
"dirtyCloseHint": "اضغط Shift للإغلاق بدون تنبيه",
|
||||
"dirtyCloseTitle": "حفظ التغييرات؟",
|
||||
"workflowTreeType": {
|
||||
|
||||
@@ -24,6 +24,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ARVideoI2V": {
|
||||
"display_name": "ARVideoI2V",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "حجم الدفعة"
|
||||
},
|
||||
"height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"length": {
|
||||
"name": "الطول"
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "الصورة_البدء"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"AddNoise": {
|
||||
"display_name": "إضافة ضجيج",
|
||||
"inputs": {
|
||||
@@ -4171,6 +4205,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNanoBanana2V2": {
|
||||
"description": "توليد أو تعديل الصور بشكل متزامن عبر Google Vertex API.",
|
||||
"display_name": "نانا موز 2",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج"
|
||||
},
|
||||
"model_aspect_ratio": {
|
||||
"name": "نسبة العرض إلى الارتفاع"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "الدقة"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "مستوى التفكير"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "الموجه",
|
||||
"tooltip": "وصف نصي للصورة المراد توليدها أو التعديلات التي يجب تطبيقها. أدرج أي قيود أو أنماط أو تفاصيل يجب على النموذج اتباعها."
|
||||
},
|
||||
"response_modalities": {
|
||||
"name": "أنماط الاستجابة"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "عند تثبيت البذرة على قيمة محددة، يبذل النموذج قصارى جهده لتقديم نفس الاستجابة للطلبات المتكررة. لا يتم ضمان إخراج حتمي. أيضًا، تغيير النموذج أو إعدادات المعلمات مثل درجة الحرارة قد يؤدي إلى اختلافات في الاستجابة حتى عند استخدام نفس قيمة البذرة. بشكل افتراضي، يتم استخدام قيمة بذرة عشوائية."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "موجه النظام",
|
||||
"tooltip": "تعليمات أساسية تحدد سلوك الذكاء الاصطناعي."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "صورة التفكير",
|
||||
"tooltip": "أول صورة من عملية تفكير النموذج. متوفرة فقط عند مستوى التفكير العالي ونمط الاستجابة صورة+نص."
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNode": {
|
||||
"description": "إنشاء استجابات نصية باستخدام نموذج الذكاء الاصطناعي Gemini من Google. يمكنك تقديم أنواع متعددة من المدخلات (نص، صور، صوت، فيديو) كسياق لإنشاء استجابات أكثر صلة ومعنى.",
|
||||
"display_name": "Google Gemini",
|
||||
@@ -7963,6 +8045,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadBackgroundRemovalModel": {
|
||||
"display_name": "تحميل نموذج إزالة الخلفية",
|
||||
"inputs": {
|
||||
"bg_removal_name": {
|
||||
"name": "اسم_إزالة_الخلفية",
|
||||
"tooltip": "النموذج المستخدم لإزالة الخلفيات من الصور"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "نموذج_الخلفية",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadImage": {
|
||||
"display_name": "تحميل صورة",
|
||||
"inputs": {
|
||||
@@ -11917,6 +12014,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpticalFlowLoader": {
|
||||
"display_name": "تحميل نموذج التدفق البصري",
|
||||
"inputs": {
|
||||
"model_name": {
|
||||
"name": "model_name",
|
||||
"tooltip": "نموذج التدفق البصري المراد تحميله. يجب وضع الملفات في مجلد 'optical_flow'. حالياً، فقط raft_large.pth من torchvision مدعوم."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"OptimalStepsScheduler": {
|
||||
"display_name": "مجدول الخطوات الأمثل",
|
||||
"inputs": {
|
||||
@@ -13398,6 +13509,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RemoveBackground": {
|
||||
"display_name": "إزالة الخلفية",
|
||||
"inputs": {
|
||||
"bg_removal_model": {
|
||||
"name": "نموذج_إزالة_الخلفية",
|
||||
"tooltip": "نموذج إزالة الخلفية المستخدم لتوليد القناع"
|
||||
},
|
||||
"image": {
|
||||
"name": "الصورة",
|
||||
"tooltip": "صورة الإدخال لإزالة الخلفية منها"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "القناع",
|
||||
"tooltip": "قناع المقدمة المُنتج"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenormCFG": {
|
||||
"display_name": "إعادة تهيئة CFG",
|
||||
"inputs": {
|
||||
@@ -17291,7 +17421,8 @@
|
||||
"name": "PBR"
|
||||
},
|
||||
"quad": {
|
||||
"name": "رباعي"
|
||||
"name": "رباعي",
|
||||
"tooltip": "هذا المعامل قديم ولم يعد له أي تأثير."
|
||||
},
|
||||
"texture": {
|
||||
"name": "الملمس"
|
||||
@@ -17811,6 +17942,127 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDInpaintConditioning": {
|
||||
"display_name": "VOIDInpaintConditioning",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "عدد إطارات البكسل للمعالجة. بالنسبة لـ CogVideoX-Fun-V1.5 (patch_size_t=2)، يجب أن يكون latent_t عدداً زوجياً — الأطوال التي تنتج latent_t فردياً يتم تقريبها للأسفل (مثال: 49 → 45)."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"quadmask": {
|
||||
"name": "quadmask",
|
||||
"tooltip": "قناع رباعي معالج مسبقاً من VOIDQuadmaskPreprocess [T, H, W]"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "إطارات الفيديو المصدر [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDQuadmaskPreprocess": {
|
||||
"display_name": "VOIDQuadmaskPreprocess",
|
||||
"inputs": {
|
||||
"dilate_width": {
|
||||
"name": "dilate_width",
|
||||
"tooltip": "نصف قطر التوسيع لمنطقة القناع الأساسية (٠ = بدون توسيع)"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "quadmask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDSampler": {
|
||||
"display_name": "VOIDSampler",
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoise": {
|
||||
"display_name": "VOIDWarpedNoise",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "عدد إطارات البكسل. يتم التقريب للأسفل لجعل latent_t عدداً زوجياً (متطلب patch_size_t=2)، مثال: 49 → 45."
|
||||
},
|
||||
"optical_flow": {
|
||||
"name": "optical_flow",
|
||||
"tooltip": "نموذج التدفق البصري من OpticalFlowLoader (RAFT-large)."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "إطارات الفيديو الناتجة من المرحلة الأولى [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoiseSource": {
|
||||
"display_name": "VOIDWarpedNoiseSource",
|
||||
"inputs": {
|
||||
"warped_noise": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": "الضجيج المشوه (latent) من VOIDWarpedNoise"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VPScheduler": {
|
||||
"display_name": "مجدول VP",
|
||||
"inputs": {
|
||||
@@ -19138,6 +19390,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "الصوت"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "مقياس حقن الصوت",
|
||||
"tooltip": "المقياس لميزات الصوت عند حقنها في نموذج الفيديو."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "إطارات الفيديو"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "مخرجات مشفر الصوت",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "سلسلة معدل الإطارات (fps)",
|
||||
"tooltip": "معدل الإطارات المحسوب بناءً على طول الصوت وعدد إطارات الفيديو. يُستخدم في الموجه."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "الصوت",
|
||||
"tooltip": "الصوت المستخدم لحساب إجمالي إطارات الإخراج واستخراج صوت المقطع."
|
||||
},
|
||||
"images": {
|
||||
"name": "الصور"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "فهرس المقطع",
|
||||
"tooltip": "أي مقطع هذا (٠ للأول، ١ للثاني، إلخ.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "طول المقطع",
|
||||
"tooltip": "طول هذا المقطع (عادةً ١٤٩ إطاراً)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تسلسل الإطارات الرئيسية المبطنة",
|
||||
"tooltip": "تسلسل الإطارات الرئيسية بعد التبطين"
|
||||
},
|
||||
"1": {
|
||||
"name": "قناع الإطارات الرئيسية",
|
||||
"tooltip": "قناع يحدد الإطارات الصالحة"
|
||||
},
|
||||
"2": {
|
||||
"name": "مقطع الصوت",
|
||||
"tooltip": "مقطع الصوت لهذا الجزء من الفيديو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "الصوت",
|
||||
"tooltip": "الصوت الذي سيتم تقطيعه لكل مقطع صادر."
|
||||
},
|
||||
"images": {
|
||||
"name": "الصور"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "عدد المقاطع",
|
||||
"tooltip": "عدد المقاطع المبطنة التي سيتم إصدارها كقوائم."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "طول المقطع",
|
||||
"tooltip": "طول كل مقطع (عادةً ١٤٩ إطاراً)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تسلسلات الإطارات الرئيسية المبطنة",
|
||||
"tooltip": "تسلسلات الإطارات الرئيسية بعد التبطين"
|
||||
},
|
||||
"1": {
|
||||
"name": "أقنعة الإطارات الرئيسية",
|
||||
"tooltip": "أقنعة تحدد الإطارات الصالحة"
|
||||
},
|
||||
"2": {
|
||||
"name": "مقطع الصوت",
|
||||
"tooltip": "مقطع الصوت لكل جزء من الفيديو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "مخرجات ترميز الصوت"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "تضمينات CLIP للرؤية للإطار الأول."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "تضمينات CLIP للرؤية لصورة المرجع."
|
||||
},
|
||||
"height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"length": {
|
||||
"name": "الطول",
|
||||
"tooltip": "عدد الإطارات في الفيديو المُنتج. يجب أن يبقى ١٤٩ لـ WanDancer."
|
||||
},
|
||||
"mask": {
|
||||
"name": "قناع",
|
||||
"tooltip": "قناع معالجة الصورة للصورة/الصور الابتدائية. الأبيض يبقى، الأسود يُولّد. يُستخدم للتوليد المحلي."
|
||||
},
|
||||
"negative": {
|
||||
"name": "سلبي"
|
||||
},
|
||||
"positive": {
|
||||
"name": "إيجابي"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "الصورة الابتدائية",
|
||||
"tooltip": "الصورة أو الصور الأولية التي سيتم ترميزها، يمكن أن تكون أي عدد من الإطارات."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "إيجابي",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "سلبي",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "كامِن",
|
||||
"tooltip": "كامِن فارغ."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "وان إطار أول وآخر إلى فيديو",
|
||||
"inputs": {
|
||||
|
||||
@@ -979,6 +979,7 @@
|
||||
"dirtyCloseTitle": "Save Changes?",
|
||||
"dirtyClose": "The files below have been changed. Would you like to save them before closing?",
|
||||
"dirtyCloseHint": "Hold Shift to close without prompt",
|
||||
"dirtyCloseAnyway": "Close anyway",
|
||||
"confirmOverwriteTitle": "Overwrite existing file?",
|
||||
"confirmOverwrite": "The file below already exists. Would you like to overwrite it?",
|
||||
"workflowTreeType": {
|
||||
@@ -1649,7 +1650,7 @@
|
||||
"Directories": "Directories"
|
||||
},
|
||||
"nodeCategories": {
|
||||
"_for_testing": "_for_testing",
|
||||
"experimental": "experimental",
|
||||
"custom_sampling": "custom_sampling",
|
||||
"noise": "noise",
|
||||
"dataset": "dataset",
|
||||
@@ -1657,8 +1658,9 @@
|
||||
"image": "image",
|
||||
"sampling": "sampling",
|
||||
"schedulers": "schedulers",
|
||||
"audio": "audio",
|
||||
"conditioning": "conditioning",
|
||||
"video_models": "video_models",
|
||||
"audio": "audio",
|
||||
"loaders": "loaders",
|
||||
"guiders": "guiders",
|
||||
"batch": "batch",
|
||||
@@ -1681,17 +1683,14 @@
|
||||
"postprocessing": "postprocessing",
|
||||
"hooks": "hooks",
|
||||
"combine": "combine",
|
||||
"math": "math",
|
||||
"logic": "logic",
|
||||
"cond single": "cond single",
|
||||
"context": "context",
|
||||
"controlnet": "controlnet",
|
||||
"inpaint": "inpaint",
|
||||
"scheduling": "scheduling",
|
||||
"create": "create",
|
||||
"deprecated": "deprecated",
|
||||
"detection": "detection",
|
||||
"": "",
|
||||
"debug": "debug",
|
||||
"model": "model",
|
||||
"ElevenLabs": "ElevenLabs",
|
||||
@@ -1702,14 +1701,14 @@
|
||||
"unet": "unet",
|
||||
"sigmas": "sigmas",
|
||||
"BFL": "BFL",
|
||||
"": "",
|
||||
"Gemini": "Gemini",
|
||||
"video_models": "video_models",
|
||||
"gligen": "gligen",
|
||||
"shader": "shader",
|
||||
"Grok": "Grok",
|
||||
"Wan": "Wan",
|
||||
"HitPaw": "HitPaw",
|
||||
"sd": "sd",
|
||||
"3d_models": "3d_models",
|
||||
"Ideogram": "Ideogram",
|
||||
"transform": "transform",
|
||||
"color": "color",
|
||||
@@ -1736,27 +1735,24 @@
|
||||
"Quiver": "Quiver",
|
||||
"Recraft": "Recraft",
|
||||
"edit_models": "edit_models",
|
||||
"background removal": "background removal",
|
||||
"Reve": "Reve",
|
||||
"Rodin": "Rodin",
|
||||
"Runway": "Runway",
|
||||
"animation": "animation",
|
||||
"api": "api",
|
||||
"save": "save",
|
||||
"upscale_diffusion": "upscale_diffusion",
|
||||
"clip": "clip",
|
||||
"Sonilo": "Sonilo",
|
||||
"Stability AI": "Stability AI",
|
||||
"stable_cascade": "stable_cascade",
|
||||
"3d_models": "3d_models",
|
||||
"style_model": "style_model",
|
||||
"supir": "supir",
|
||||
"Tencent": "Tencent",
|
||||
"textgen": "textgen",
|
||||
"Topaz": "Topaz",
|
||||
"Tripo": "Tripo",
|
||||
"Veo": "Veo",
|
||||
"Vidu": "Vidu",
|
||||
"camera": "camera",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
"zimage": "zimage"
|
||||
},
|
||||
@@ -1766,6 +1762,7 @@
|
||||
"AUDIO_ENCODER": "AUDIO_ENCODER",
|
||||
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
|
||||
"AUDIO_RECORD": "AUDIO_RECORD",
|
||||
"BACKGROUND_REMOVAL": "BACKGROUND_REMOVAL",
|
||||
"BOOLEAN": "BOOLEAN",
|
||||
"BOUNDING_BOX": "BOUNDING_BOX",
|
||||
"CAMERA_CONTROL": "CAMERA_CONTROL",
|
||||
@@ -1819,6 +1816,7 @@
|
||||
"NOISE": "NOISE",
|
||||
"OPENAI_CHAT_CONFIG": "OPENAI_CHAT_CONFIG",
|
||||
"OPENAI_INPUT_FILES": "OPENAI_INPUT_FILES",
|
||||
"OPTICAL_FLOW": "OPTICAL_FLOW",
|
||||
"PHOTOMAKER": "PHOTOMAKER",
|
||||
"PIXVERSE_TEMPLATE": "PIXVERSE_TEMPLATE",
|
||||
"POSE_KEYPOINT": "POSE_KEYPOINT",
|
||||
@@ -2210,7 +2208,9 @@
|
||||
"success": "Signed out successfully",
|
||||
"successDetail": "You have been signed out of your account.",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?"
|
||||
"unsavedChangesMessage": "You have unsaved changes that will be lost when you sign out. Do you want to continue?",
|
||||
"signOutAnyway": "Sign out anyway",
|
||||
"saveFailed": "Sign-out cancelled because saving \"{workflow}\" failed."
|
||||
},
|
||||
"passwordUpdate": {
|
||||
"success": "Password Updated",
|
||||
|
||||
@@ -141,6 +141,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ARVideoI2V": {
|
||||
"display_name": "ARVideoI2V",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "start_image"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length"
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"AudioAdjustVolume": {
|
||||
"display_name": "Audio Adjust Volume",
|
||||
"inputs": {
|
||||
@@ -196,7 +230,7 @@
|
||||
}
|
||||
},
|
||||
"AudioEncoderLoader": {
|
||||
"display_name": "AudioEncoderLoader",
|
||||
"display_name": "Load Audio Encoder",
|
||||
"inputs": {
|
||||
"audio_encoder_name": {
|
||||
"name": "audio_encoder_name"
|
||||
@@ -1185,7 +1219,7 @@
|
||||
},
|
||||
"CLIPLoader": {
|
||||
"display_name": "Load CLIP",
|
||||
"description": "[Recipes]\n\nstable_diffusion: clip-l\nstable_cascade: clip-g\nsd3: t5 xxl/ clip-g / clip-l\nstable_audio: t5 base\nmochi: t5 xxl\ncosmos: old t5 xxl\nlumina2: gemma 2 2B\nwan: umt5 xxl\n hidream: llama-3.1 (Recommend) or t5\nomnigen2: qwen vl 2.5 3B",
|
||||
"description": "[Recipes]\n\nstable_diffusion: clip-l\nstable_cascade: clip-g\nsd3: t5 xxl/ clip-g / clip-l\nstable_audio: t5 base\nmochi: t5 xxl\ncogvideox: t5 xxl (226-token padding)\ncosmos: old t5 xxl\nlumina2: gemma 2 2B\nwan: umt5 xxl\n hidream: llama-3.1 (Recommend) or t5\nomnigen2: qwen vl 2.5 3B",
|
||||
"inputs": {
|
||||
"clip_name": {
|
||||
"name": "clip_name"
|
||||
@@ -4063,6 +4097,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNanoBanana2V2": {
|
||||
"display_name": "Nano Banana 2",
|
||||
"description": "Generate or edit images synchronously via Google Vertex API.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text prompt describing the image to generate or the edits to apply. Include any constraints, styles, or details the model should follow."
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "When the seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used."
|
||||
},
|
||||
"response_modalities": {
|
||||
"name": "response_modalities"
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "system_prompt",
|
||||
"tooltip": "Foundational instructions that dictate an AI's behavior."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "thinking_level"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "thought_image",
|
||||
"tooltip": "First image from the model's thinking process. Only available with thinking_level HIGH and IMAGE+TEXT modality."
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNode": {
|
||||
"display_name": "Google Gemini",
|
||||
"description": "Generate text responses with Google's Gemini AI model. You can provide multiple types of inputs (text, images, audio, video) as context for generating more relevant and meaningful responses.",
|
||||
@@ -4242,7 +4324,7 @@
|
||||
}
|
||||
},
|
||||
"GLIGENLoader": {
|
||||
"display_name": "GLIGENLoader",
|
||||
"display_name": "Load GLIGEN Model",
|
||||
"inputs": {
|
||||
"gligen_name": {
|
||||
"name": "gligen_name"
|
||||
@@ -4839,7 +4921,7 @@
|
||||
}
|
||||
},
|
||||
"HunyuanRefinerLatent": {
|
||||
"display_name": "HunyuanRefinerLatent",
|
||||
"display_name": "Hunyuan Latent Refiner",
|
||||
"inputs": {
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
@@ -4944,7 +5026,7 @@
|
||||
}
|
||||
},
|
||||
"HunyuanVideo15SuperResolution": {
|
||||
"display_name": "HunyuanVideo15SuperResolution",
|
||||
"display_name": "Hunyuan Video 1.5 Super Resolution",
|
||||
"inputs": {
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
@@ -4984,7 +5066,7 @@
|
||||
}
|
||||
},
|
||||
"HypernetworkLoader": {
|
||||
"display_name": "HypernetworkLoader",
|
||||
"display_name": "Load Hypernetwork",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
@@ -5280,9 +5362,6 @@
|
||||
"ImageCompositeMasked": {
|
||||
"display_name": "Image Composite Masked",
|
||||
"inputs": {
|
||||
"destination": {
|
||||
"name": "destination"
|
||||
},
|
||||
"source": {
|
||||
"name": "source"
|
||||
},
|
||||
@@ -5295,6 +5374,9 @@
|
||||
"resize_source": {
|
||||
"name": "resize_source"
|
||||
},
|
||||
"destination": {
|
||||
"name": "destination"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
}
|
||||
@@ -5544,7 +5626,7 @@
|
||||
}
|
||||
},
|
||||
"ImageQuantize": {
|
||||
"display_name": "ImageQuantize",
|
||||
"display_name": "Quantize Image",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
@@ -5676,7 +5758,7 @@
|
||||
}
|
||||
},
|
||||
"ImageSharpen": {
|
||||
"display_name": "ImageSharpen",
|
||||
"display_name": "Sharpen Image",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
@@ -7550,6 +7632,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadBackgroundRemovalModel": {
|
||||
"display_name": "Load Background Removal Model",
|
||||
"inputs": {
|
||||
"bg_removal_name": {
|
||||
"name": "bg_removal_name",
|
||||
"tooltip": "The model used to remove backgrounds from images"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "bg_model",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadImage": {
|
||||
"display_name": "Load Image",
|
||||
"inputs": {
|
||||
@@ -8211,7 +8308,7 @@
|
||||
}
|
||||
},
|
||||
"LTXVPreprocess": {
|
||||
"display_name": "LTXVPreprocess",
|
||||
"display_name": "LTXV Preprocess",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
@@ -11917,6 +12014,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpticalFlowLoader": {
|
||||
"display_name": "Load Optical Flow Model",
|
||||
"inputs": {
|
||||
"model_name": {
|
||||
"name": "model_name",
|
||||
"tooltip": "Optical flow model to load. Files must be placed in the 'optical_flow' folder. Today only torchvision's raft_large.pth is supported."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"OptimalStepsScheduler": {
|
||||
"display_name": "OptimalStepsScheduler",
|
||||
"inputs": {
|
||||
@@ -12127,7 +12238,7 @@
|
||||
}
|
||||
},
|
||||
"PerpNeg": {
|
||||
"display_name": "Perp-Neg (DEPRECATED by PerpNegGuider)",
|
||||
"display_name": "Perp-Neg (DEPRECATED by Perp-Neg Guider)",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
@@ -12146,7 +12257,7 @@
|
||||
}
|
||||
},
|
||||
"PerpNegGuider": {
|
||||
"display_name": "PerpNegGuider",
|
||||
"display_name": "Perp-Neg Guider",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
@@ -13370,6 +13481,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RemoveBackground": {
|
||||
"display_name": "Remove Background",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Input image to remove the background from"
|
||||
},
|
||||
"bg_removal_model": {
|
||||
"name": "bg_removal_model",
|
||||
"tooltip": "Background removal model used to generate the mask"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "mask",
|
||||
"tooltip": "Generated foreground mask"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenormCFG": {
|
||||
"display_name": "RenormCFG",
|
||||
"inputs": {
|
||||
@@ -14123,11 +14253,11 @@
|
||||
},
|
||||
"detection_threshold": {
|
||||
"name": "detection_threshold",
|
||||
"tooltip": "Score threshold for text-prompted detection"
|
||||
"tooltip": "Score threshold for text-prompted detection."
|
||||
},
|
||||
"max_objects": {
|
||||
"name": "max_objects",
|
||||
"tooltip": "Max tracked objects (0=unlimited). Initial masks count toward this limit."
|
||||
"tooltip": "Max tracked objects. Initial masks count toward this limit. 0 uses the internal cap of 64."
|
||||
},
|
||||
"detect_interval": {
|
||||
"name": "detect_interval",
|
||||
@@ -14697,7 +14827,7 @@
|
||||
}
|
||||
},
|
||||
"SaveImageWebsocket": {
|
||||
"display_name": "SaveImageWebsocket",
|
||||
"display_name": "Save Image (Websocket)",
|
||||
"inputs": {
|
||||
"images": {
|
||||
"name": "images"
|
||||
@@ -16619,7 +16749,7 @@
|
||||
}
|
||||
},
|
||||
"TextGenerate": {
|
||||
"display_name": "TextGenerate",
|
||||
"display_name": "Generate Text",
|
||||
"inputs": {
|
||||
"clip": {
|
||||
"name": "clip"
|
||||
@@ -16681,7 +16811,7 @@
|
||||
}
|
||||
},
|
||||
"TextGenerateLTX2Prompt": {
|
||||
"display_name": "TextGenerateLTX2Prompt",
|
||||
"display_name": "Generate LTX2 Prompt",
|
||||
"inputs": {
|
||||
"clip": {
|
||||
"name": "clip"
|
||||
@@ -17300,7 +17430,8 @@
|
||||
"name": "face_limit"
|
||||
},
|
||||
"quad": {
|
||||
"name": "quad"
|
||||
"name": "quad",
|
||||
"tooltip": "This parameter is deprecated and does nothing."
|
||||
},
|
||||
"geometry_quality": {
|
||||
"name": "geometry_quality"
|
||||
@@ -17507,7 +17638,7 @@
|
||||
}
|
||||
},
|
||||
"unCLIPCheckpointLoader": {
|
||||
"display_name": "unCLIPCheckpointLoader",
|
||||
"display_name": "Load unCLIP Checkpoint",
|
||||
"inputs": {
|
||||
"ckpt_name": {
|
||||
"name": "ckpt_name"
|
||||
@@ -18575,8 +18706,129 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDInpaintConditioning": {
|
||||
"display_name": "VOIDInpaintConditioning",
|
||||
"inputs": {
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Source video frames [T, H, W, 3]"
|
||||
},
|
||||
"quadmask": {
|
||||
"name": "quadmask",
|
||||
"tooltip": "Preprocessed quadmask from VOIDQuadmaskPreprocess [T, H, W]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "Number of pixel frames to process. For CogVideoX-Fun-V1.5 (patch_size_t=2), latent_t must be even — lengths that produce odd latent_t are rounded down (e.g. 49 → 45)."
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDQuadmaskPreprocess": {
|
||||
"display_name": "VOIDQuadmaskPreprocess",
|
||||
"inputs": {
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
},
|
||||
"dilate_width": {
|
||||
"name": "dilate_width",
|
||||
"tooltip": "Dilation radius for the primary mask region (0 = no dilation)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "quadmask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDSampler": {
|
||||
"display_name": "VOIDSampler",
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoise": {
|
||||
"display_name": "VOIDWarpedNoise",
|
||||
"inputs": {
|
||||
"optical_flow": {
|
||||
"name": "optical_flow",
|
||||
"tooltip": "Optical flow model from OpticalFlowLoader (RAFT-large)."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Pass 1 output video frames [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "Number of pixel frames. Rounded down to make latent_t even (patch_size_t=2 requirement), e.g. 49 → 45."
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoiseSource": {
|
||||
"display_name": "VOIDWarpedNoiseSource",
|
||||
"inputs": {
|
||||
"warped_noise": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": "Warped noise latent from VOIDWarpedNoise"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoxelToMesh": {
|
||||
"display_name": "VoxelToMesh",
|
||||
"display_name": "Voxel to Mesh",
|
||||
"inputs": {
|
||||
"voxel": {
|
||||
"name": "voxel"
|
||||
@@ -18595,7 +18847,7 @@
|
||||
}
|
||||
},
|
||||
"VoxelToMeshBasic": {
|
||||
"display_name": "VoxelToMeshBasic",
|
||||
"display_name": "Voxel to Mesh (Basic)",
|
||||
"inputs": {
|
||||
"voxel": {
|
||||
"name": "voxel"
|
||||
@@ -19177,6 +19429,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "The scale for the audio features when injected into the video model."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "The calculated fps based on the audio length and the number of video frames. Used in the prompt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Length of this segment (usually 149 frames)"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "Which segment this is (0 for first, 1 for second, etc.)"
|
||||
},
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio to calculate total output frames from and extract segment audio."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Padded keyframe sequence"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Mask indicating valid frames"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Audio segment for this video segment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Length of each segment (usually 149 frames)"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "How many padded segments to emit as lists."
|
||||
},
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio to slice for each emitted segment."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Padded keyframe sequences"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Masks indicating valid frames"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Audio segment for each video segment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "The number of frames in the generated video. Should stay 149 for WanDancer."
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "The CLIP vision embeds for the first frame."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "The CLIP vision embeds for the reference image."
|
||||
},
|
||||
"start_image": {
|
||||
"name": "start_image",
|
||||
"tooltip": "The initial image(s) to be encoded, can be any number of frames."
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "Image conditioning mask for the start image(s). White is kept, black is generated. Used for the local generations."
|
||||
},
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "Empty latent."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
},
|
||||
"login": {
|
||||
"andText": "y",
|
||||
"backToGithubLogin": "Regístrate con Github en su lugar",
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"backToSocialLogin": "Regístrate con Google o Github en su lugar",
|
||||
"confirmPasswordLabel": "Confirmar contraseña",
|
||||
@@ -292,7 +293,9 @@
|
||||
"title": "Reautenticación requerida"
|
||||
},
|
||||
"signOut": {
|
||||
"saveFailed": "Cierre de sesión cancelado porque falló el guardado de \"{workflow}\".",
|
||||
"signOut": "Cerrar sesión",
|
||||
"signOutAnyway": "Cerrar sesión de todos modos",
|
||||
"success": "Sesión cerrada correctamente",
|
||||
"successDetail": "Has cerrado sesión en tu cuenta.",
|
||||
"unsavedChangesMessage": "Tienes cambios no guardados que se perderán al cerrar sesión. ¿Quieres continuar?",
|
||||
@@ -782,6 +785,7 @@
|
||||
"AUDIO_ENCODER": "CODIFICADOR_AUDIO",
|
||||
"AUDIO_ENCODER_OUTPUT": "SALIDA_CODIFICADOR_AUDIO",
|
||||
"AUDIO_RECORD": "GRABACIÓN_AUDIO",
|
||||
"BACKGROUND_REMOVAL": "ELIMINACIÓN_DE_FONDO",
|
||||
"BOOLEAN": "BOOLEANO",
|
||||
"BOUNDING_BOX": "CUADRO DELIMITADOR",
|
||||
"CAMERA_CONTROL": "CONTROL DE CÁMARA",
|
||||
@@ -835,6 +839,7 @@
|
||||
"NOISE": "RUIDO",
|
||||
"OPENAI_CHAT_CONFIG": "CONFIGURACIÓN_CHAT_OPENAI",
|
||||
"OPENAI_INPUT_FILES": "ARCHIVOS_ENTRADA_OPENAI",
|
||||
"OPTICAL_FLOW": "OPTICAL_FLOW",
|
||||
"PHOTOMAKER": "PHOTOMAKER",
|
||||
"PIXVERSE_TEMPLATE": "PLANTILLA PIXVERSE",
|
||||
"POSE_KEYPOINT": "POSE_KEYPOINT",
|
||||
@@ -2280,15 +2285,13 @@
|
||||
"Vidu": "Vidu",
|
||||
"Wan": "Wan",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
"_for_testing": "_para_pruebas",
|
||||
"advanced": "avanzado",
|
||||
"animation": "animación",
|
||||
"api": "api",
|
||||
"api node": "nodo api",
|
||||
"attention_experiments": "experimentos_de_atención",
|
||||
"audio": "audio",
|
||||
"background removal": "eliminación de fondo",
|
||||
"batch": "lote",
|
||||
"camera": "cámara",
|
||||
"chroma_radiance": "chroma_radiance",
|
||||
"clip": "clip",
|
||||
"color": "color",
|
||||
@@ -2297,7 +2300,6 @@
|
||||
"cond pair": "par_cond",
|
||||
"cond single": "cond único",
|
||||
"conditioning": "acondicionamiento",
|
||||
"context": "contexto",
|
||||
"controlnet": "controlnet",
|
||||
"create": "crear",
|
||||
"custom_sampling": "muestreo_personalizado",
|
||||
@@ -2306,6 +2308,7 @@
|
||||
"deprecated": "obsoleto",
|
||||
"detection": "detección",
|
||||
"edit_models": "editar_modelos",
|
||||
"experimental": "experimental",
|
||||
"flux": "flux",
|
||||
"gligen": "gligen",
|
||||
"guidance": "orientación",
|
||||
@@ -2321,7 +2324,6 @@
|
||||
"lotus": "lotus",
|
||||
"ltxv": "ltxv",
|
||||
"mask": "mask",
|
||||
"math": "matemáticas",
|
||||
"model": "modelo",
|
||||
"model_merging": "fusión_de_modelos",
|
||||
"model_patches": "parches_de_modelo",
|
||||
@@ -2338,7 +2340,6 @@
|
||||
"save": "guardar",
|
||||
"schedulers": "programadores",
|
||||
"scheduling": "programación",
|
||||
"sd": "sd",
|
||||
"sd3": "sd3",
|
||||
"shader": "shader",
|
||||
"sigmas": "sigmas",
|
||||
@@ -2346,7 +2347,6 @@
|
||||
"style_model": "modelo_de_estilo",
|
||||
"supir": "supir",
|
||||
"text": "texto",
|
||||
"textgen": "textgen",
|
||||
"training": "entrenamiento",
|
||||
"transform": "transformar",
|
||||
"unet": "unet",
|
||||
@@ -3150,6 +3150,7 @@
|
||||
"deleteFailedTitle": "Eliminación fallida",
|
||||
"deleted": "Flujo de trabajo eliminado",
|
||||
"dirtyClose": "Los archivos a continuación han sido modificados. ¿Te gustaría guardarlos antes de cerrar?",
|
||||
"dirtyCloseAnyway": "Cerrar de todos modos",
|
||||
"dirtyCloseHint": "Mantén presionada la tecla Shift para cerrar sin preguntar",
|
||||
"dirtyCloseTitle": "¿Guardar cambios?",
|
||||
"workflowTreeType": {
|
||||
|
||||
@@ -24,6 +24,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ARVideoI2V": {
|
||||
"display_name": "ARVideoI2V",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "tamaño_de_lote"
|
||||
},
|
||||
"height": {
|
||||
"name": "alto"
|
||||
},
|
||||
"length": {
|
||||
"name": "longitud"
|
||||
},
|
||||
"model": {
|
||||
"name": "modelo"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "imagen_inicial"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "ancho"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"AddNoise": {
|
||||
"display_name": "AñadirRuido",
|
||||
"inputs": {
|
||||
@@ -4171,6 +4205,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNanoBanana2V2": {
|
||||
"description": "Genera o edita imágenes de forma síncrona a través de la API de Google Vertex.",
|
||||
"display_name": "Nano Banana 2",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"model_aspect_ratio": {
|
||||
"name": "aspect_ratio"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "resolution"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "thinking_level"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Texto descriptivo de la imagen a generar o de las ediciones a aplicar. Incluye cualquier restricción, estilo o detalle que el modelo deba seguir."
|
||||
},
|
||||
"response_modalities": {
|
||||
"name": "response_modalities"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Cuando la semilla se fija a un valor específico, el modelo intenta proporcionar la misma respuesta en solicitudes repetidas. No se garantiza una salida determinista. Además, cambiar el modelo o los parámetros, como la temperatura, puede causar variaciones en la respuesta incluso usando la misma semilla. Por defecto, se utiliza una semilla aleatoria."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "system_prompt",
|
||||
"tooltip": "Instrucciones fundamentales que dictan el comportamiento de la IA."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "thought_image",
|
||||
"tooltip": "Primera imagen del proceso de pensamiento del modelo. Solo disponible con thinking_level ALTO y modalidad IMAGEN+TEXTO."
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNode": {
|
||||
"description": "Genera respuestas de texto con el modelo de IA Gemini de Google. Puede proporcionar múltiples tipos de entradas (texto, imágenes, audio, video) como contexto para generar respuestas más relevantes y significativas.",
|
||||
"display_name": "Google Gemini",
|
||||
@@ -7963,6 +8045,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadBackgroundRemovalModel": {
|
||||
"display_name": "Cargar modelo de eliminación de fondo",
|
||||
"inputs": {
|
||||
"bg_removal_name": {
|
||||
"name": "nombre_del_modelo_de_eliminación_de_fondo",
|
||||
"tooltip": "El modelo utilizado para eliminar fondos de imágenes"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "modelo_de_fondo",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadImage": {
|
||||
"display_name": "Cargar Imagen",
|
||||
"inputs": {
|
||||
@@ -11917,6 +12014,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpticalFlowLoader": {
|
||||
"display_name": "Cargar modelo de flujo óptico",
|
||||
"inputs": {
|
||||
"model_name": {
|
||||
"name": "model_name",
|
||||
"tooltip": "Modelo de flujo óptico a cargar. Los archivos deben colocarse en la carpeta 'optical_flow'. Actualmente solo se admite raft_large.pth de torchvision."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"OptimalStepsScheduler": {
|
||||
"display_name": "OptimalStepsScheduler",
|
||||
"inputs": {
|
||||
@@ -13398,6 +13509,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RemoveBackground": {
|
||||
"display_name": "Eliminar fondo",
|
||||
"inputs": {
|
||||
"bg_removal_model": {
|
||||
"name": "modelo_de_eliminación_de_fondo",
|
||||
"tooltip": "Modelo de eliminación de fondo utilizado para generar la máscara"
|
||||
},
|
||||
"image": {
|
||||
"name": "imagen",
|
||||
"tooltip": "Imagen de entrada para eliminar el fondo"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "máscara",
|
||||
"tooltip": "Máscara de primer plano generada"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenormCFG": {
|
||||
"display_name": "RenormCFG",
|
||||
"inputs": {
|
||||
@@ -17291,7 +17421,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "cuadrilátero"
|
||||
"name": "cuadrilátero",
|
||||
"tooltip": "Este parámetro está obsoleto y no hace nada."
|
||||
},
|
||||
"texture": {
|
||||
"name": "textura"
|
||||
@@ -17811,6 +17942,127 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDInpaintConditioning": {
|
||||
"display_name": "VOIDInpaintConditioning",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "Número de fotogramas de píxeles a procesar. Para CogVideoX-Fun-V1.5 (patch_size_t=2), latent_t debe ser par — las longitudes que produzcan latent_t impar se redondean hacia abajo (por ejemplo, 49 → 45)."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"quadmask": {
|
||||
"name": "quadmask",
|
||||
"tooltip": "Quadmask preprocesada de VOIDQuadmaskPreprocess [T, H, W]"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Fotogramas de video de origen [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDQuadmaskPreprocess": {
|
||||
"display_name": "VOIDQuadmaskPreprocess",
|
||||
"inputs": {
|
||||
"dilate_width": {
|
||||
"name": "dilate_width",
|
||||
"tooltip": "Radio de dilatación para la región principal de la máscara (0 = sin dilatación)"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "quadmask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDSampler": {
|
||||
"display_name": "VOIDSampler",
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoise": {
|
||||
"display_name": "VOIDWarpedNoise",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "Número de fotogramas de píxeles. Se redondea hacia abajo para que latent_t sea par (requisito de patch_size_t=2), por ejemplo, 49 → 45."
|
||||
},
|
||||
"optical_flow": {
|
||||
"name": "optical_flow",
|
||||
"tooltip": "Modelo de flujo óptico de OpticalFlowLoader (RAFT-large)."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Salida de fotogramas de video de la pasada 1 [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoiseSource": {
|
||||
"display_name": "VOIDWarpedNoiseSource",
|
||||
"inputs": {
|
||||
"warped_noise": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": "Latente de ruido deformado de VOIDWarpedNoise"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VPScheduler": {
|
||||
"display_name": "VPScheduler",
|
||||
"inputs": {
|
||||
@@ -19138,6 +19390,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "La escala para las características de audio cuando se inyectan en el modelo de video."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "Los fps calculados en base a la duración del audio y el número de fotogramas de video. Se usa en el prompt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio para calcular el total de fotogramas de salida y extraer el audio del segmento."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "Qué segmento es este (0 para el primero, 1 para el segundo, etc.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Longitud de este segmento (usualmente 149 fotogramas)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Secuencia de keyframes rellenada"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Máscara que indica los fotogramas válidos"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Segmento de audio para este segmento de video"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio para dividir para cada segmento emitido."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "Cuántos segmentos rellenados emitir como listas."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Longitud de cada segmento (usualmente 149 fotogramas)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Secuencias de keyframes rellenadas"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Máscaras que indican los fotogramas válidos"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Segmento de audio para cada segmento de video"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "Las incrustaciones de visión de CLIP para el primer fotograma."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "Las incrustaciones de visión de CLIP para la imagen de referencia."
|
||||
},
|
||||
"height": {
|
||||
"name": "alto"
|
||||
},
|
||||
"length": {
|
||||
"name": "longitud",
|
||||
"tooltip": "El número de fotogramas en el video generado. Debe mantenerse en 149 para WanDancer."
|
||||
},
|
||||
"mask": {
|
||||
"name": "máscara",
|
||||
"tooltip": "Máscara de acondicionamiento de imagen para la(s) imagen(es) inicial(es). El blanco se mantiene, el negro se genera. Se utiliza para las generaciones locales."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negativo"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positivo"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "imagen_inicial",
|
||||
"tooltip": "La(s) imagen(es) inicial(es) a codificar, puede ser cualquier cantidad de fotogramas."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "ancho"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positivo",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negativo",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latente",
|
||||
"tooltip": "Latente vacío."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
},
|
||||
"login": {
|
||||
"andText": "و",
|
||||
"backToGithubLogin": "ثبتنام با گیتهاب",
|
||||
"backToLogin": "بازگشت به ورود",
|
||||
"backToSocialLogin": "ثبتنام با Google یا Github",
|
||||
"confirmPasswordLabel": "تأیید رمز عبور",
|
||||
@@ -292,7 +293,9 @@
|
||||
"title": "احراز هویت مجدد لازم است"
|
||||
},
|
||||
"signOut": {
|
||||
"saveFailed": "خروج لغو شد زیرا ذخیره «{workflow}» با شکست مواجه شد.",
|
||||
"signOut": "خروج",
|
||||
"signOutAnyway": "خروج به هر حال",
|
||||
"success": "خروج با موفقیت انجام شد",
|
||||
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید.",
|
||||
"unsavedChangesMessage": "شما تغییرات ذخیرهنشدهای دارید که با خروج از حساب از بین خواهند رفت. آیا مایل به ادامه هستید؟",
|
||||
@@ -782,6 +785,7 @@
|
||||
"AUDIO_ENCODER": "رمزگذار صوت",
|
||||
"AUDIO_ENCODER_OUTPUT": "خروجی رمزگذار صوت",
|
||||
"AUDIO_RECORD": "ضبط صوت",
|
||||
"BACKGROUND_REMOVAL": "حذف پسزمینه",
|
||||
"BOOLEAN": "بولی",
|
||||
"BOUNDING_BOX": "BOUNDING_BOX",
|
||||
"CAMERA_CONTROL": "کنترل دوربین",
|
||||
@@ -835,6 +839,7 @@
|
||||
"NOISE": "نویز",
|
||||
"OPENAI_CHAT_CONFIG": "پیکربندی گفتگوی OpenAI",
|
||||
"OPENAI_INPUT_FILES": "فایلهای ورودی OpenAI",
|
||||
"OPTICAL_FLOW": "OPTICAL_FLOW",
|
||||
"PHOTOMAKER": "photomaker",
|
||||
"PIXVERSE_TEMPLATE": "قالب Pixverse",
|
||||
"POSE_KEYPOINT": "POSE_KEYPOINT",
|
||||
@@ -2280,15 +2285,13 @@
|
||||
"Vidu": "Vidu",
|
||||
"Wan": "Wan",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
"_for_testing": "_for_testing",
|
||||
"advanced": "پیشرفته",
|
||||
"animation": "انیمیشن",
|
||||
"api": "API",
|
||||
"api node": "گره API",
|
||||
"attention_experiments": "آزمایشهای توجه",
|
||||
"audio": "صدا",
|
||||
"background removal": "حذف پسزمینه",
|
||||
"batch": "دستهای",
|
||||
"camera": "دوربین",
|
||||
"chroma_radiance": "درخشندگی رنگی",
|
||||
"clip": "clip",
|
||||
"color": "رنگ",
|
||||
@@ -2297,7 +2300,6 @@
|
||||
"cond pair": "جفت شرط",
|
||||
"cond single": "شرط تکی",
|
||||
"conditioning": "شرطگذاری",
|
||||
"context": "زمینه",
|
||||
"controlnet": "controlnet",
|
||||
"create": "ایجاد",
|
||||
"custom_sampling": "نمونهگیری سفارشی",
|
||||
@@ -2306,6 +2308,7 @@
|
||||
"deprecated": "منسوخ",
|
||||
"detection": "شناسایی",
|
||||
"edit_models": "ویرایش مدلها",
|
||||
"experimental": "آزمایشی",
|
||||
"flux": "flux",
|
||||
"gligen": "gligen",
|
||||
"guidance": "راهنمایی",
|
||||
@@ -2321,7 +2324,6 @@
|
||||
"lotus": "lotus",
|
||||
"ltxv": "ltxv",
|
||||
"mask": "ماسک",
|
||||
"math": "ریاضی",
|
||||
"model": "مدل",
|
||||
"model_merging": "ادغام مدل",
|
||||
"model_patches": "وصلههای مدل",
|
||||
@@ -2338,7 +2340,6 @@
|
||||
"save": "ذخیره",
|
||||
"schedulers": "زمانبندیها",
|
||||
"scheduling": "زمانبندی",
|
||||
"sd": "sd",
|
||||
"sd3": "sd3",
|
||||
"shader": "shader",
|
||||
"sigmas": "سیگماها",
|
||||
@@ -2346,7 +2347,6 @@
|
||||
"style_model": "مدل سبک",
|
||||
"supir": "supir",
|
||||
"text": "متن",
|
||||
"textgen": "textgen",
|
||||
"training": "آموزش",
|
||||
"transform": "تبدیل",
|
||||
"unet": "unet",
|
||||
@@ -3162,6 +3162,7 @@
|
||||
"deleteFailedTitle": "حذف ناموفق بود",
|
||||
"deleted": "Workflow حذف شد",
|
||||
"dirtyClose": "فایلهای زیر تغییر کردهاند. آیا مایل به ذخیره آنها قبل از بستن هستید؟",
|
||||
"dirtyCloseAnyway": "بستن به هر حال",
|
||||
"dirtyCloseHint": "برای بستن بدون پیام، Shift را نگه دارید",
|
||||
"dirtyCloseTitle": "ذخیره تغییرات؟",
|
||||
"workflowTreeType": {
|
||||
|
||||
@@ -24,6 +24,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ARVideoI2V": {
|
||||
"display_name": "ARVideoI2V",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "اندازه بچ"
|
||||
},
|
||||
"height": {
|
||||
"name": "ارتفاع"
|
||||
},
|
||||
"length": {
|
||||
"name": "طول"
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "تصویر اولیه"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "عرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"AddNoise": {
|
||||
"display_name": "AddNoise",
|
||||
"inputs": {
|
||||
@@ -4171,6 +4205,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNanoBanana2V2": {
|
||||
"description": "تولید یا ویرایش تصاویر به صورت همزمان از طریق Google Vertex API.",
|
||||
"display_name": "نانو بنانا ۲",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل"
|
||||
},
|
||||
"model_aspect_ratio": {
|
||||
"name": "نسبت تصویر"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "رزولوشن"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "سطح تفکر مدل"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "پرامپت",
|
||||
"tooltip": "متن پرامپت برای توصیف تصویری که باید تولید شود یا ویرایشهایی که باید اعمال شوند. هرگونه محدودیت، سبک یا جزئیاتی که مدل باید رعایت کند را وارد کنید."
|
||||
},
|
||||
"response_modalities": {
|
||||
"name": "حالتهای پاسخ"
|
||||
},
|
||||
"seed": {
|
||||
"name": "بذر",
|
||||
"tooltip": "وقتی مقدار بذر روی یک عدد خاص تنظیم شود، مدل تلاش میکند تا برای درخواستهای تکراری، پاسخ مشابهی ارائه دهد. خروجی قطعی تضمین نمیشود. همچنین تغییر مدل یا تنظیمات پارامترها مانند دما میتواند حتی با همان مقدار بذر، باعث تغییر در پاسخ شود. به طور پیشفرض، مقدار بذر به صورت تصادفی انتخاب میشود."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "پرامپت سیستمی",
|
||||
"tooltip": "دستورالعملهای پایهای که رفتار هوش مصنوعی را تعیین میکند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "تصویر تفکر",
|
||||
"tooltip": "اولین تصویر از فرایند تفکر مدل. فقط در صورت انتخاب سطح تفکر HIGH و حالت پاسخ IMAGE+TEXT در دسترس است."
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNode": {
|
||||
"description": "تولید پاسخ متنی با مدل هوش مصنوعی Gemini گوگل. میتوانید انواع مختلفی از ورودیها (متن، تصویر، صوت، ویدئو) را به عنوان زمینه برای تولید پاسخهای مرتبطتر و معنادارتر ارائه دهید.",
|
||||
"display_name": "Google Gemini",
|
||||
@@ -7963,6 +8045,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadBackgroundRemovalModel": {
|
||||
"display_name": "بارگذاری مدل حذف پسزمینه",
|
||||
"inputs": {
|
||||
"bg_removal_name": {
|
||||
"name": "نام مدل حذف پسزمینه",
|
||||
"tooltip": "مدلی که برای حذف پسزمینه از تصاویر استفاده میشود"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "مدل حذف پسزمینه",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadImage": {
|
||||
"display_name": "بارگذاری تصویر",
|
||||
"inputs": {
|
||||
@@ -11917,6 +12014,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpticalFlowLoader": {
|
||||
"display_name": "بارگذاری مدل Optical Flow",
|
||||
"inputs": {
|
||||
"model_name": {
|
||||
"name": "model_name",
|
||||
"tooltip": "مدل optical flow برای بارگذاری. فایلها باید در پوشه 'optical_flow' قرار گیرند. در حال حاضر فقط raft_large.pth از torchvision پشتیبانی میشود."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"OptimalStepsScheduler": {
|
||||
"display_name": "OptimalStepsScheduler",
|
||||
"inputs": {
|
||||
@@ -13398,6 +13509,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RemoveBackground": {
|
||||
"display_name": "حذف پسزمینه",
|
||||
"inputs": {
|
||||
"bg_removal_model": {
|
||||
"name": "مدل حذف پسزمینه",
|
||||
"tooltip": "مدل حذف پسزمینه که برای تولید ماسک استفاده میشود"
|
||||
},
|
||||
"image": {
|
||||
"name": "تصویر",
|
||||
"tooltip": "تصویر ورودی برای حذف پسزمینه"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "ماسک",
|
||||
"tooltip": "ماسک پیشزمینه تولید شده"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenormCFG": {
|
||||
"display_name": "RenormCFG",
|
||||
"inputs": {
|
||||
@@ -17291,7 +17421,8 @@
|
||||
"name": "PBR"
|
||||
},
|
||||
"quad": {
|
||||
"name": "چهارضلعی"
|
||||
"name": "چهارضلعی",
|
||||
"tooltip": "این پارامتر منسوخ شده است و هیچ تأثیری ندارد."
|
||||
},
|
||||
"texture": {
|
||||
"name": "تکسچر"
|
||||
@@ -17811,6 +17942,127 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDInpaintConditioning": {
|
||||
"display_name": "VOIDInpaintConditioning",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "تعداد فریمهای پیکسلی برای پردازش. برای CogVideoX-Fun-V1.5 (patch_size_t=۲)، latent_t باید زوج باشد — طولهایی که latent_t فرد تولید میکنند گرد به پایین میشوند (مثلاً ۴۹ → ۴۵)."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"quadmask": {
|
||||
"name": "quadmask",
|
||||
"tooltip": "quadmask پیشپردازششده از VOIDQuadmaskPreprocess [T, H, W]"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "فریمهای ویدیوی منبع [T, H, W, ۳]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDQuadmaskPreprocess": {
|
||||
"display_name": "VOIDQuadmaskPreprocess",
|
||||
"inputs": {
|
||||
"dilate_width": {
|
||||
"name": "dilate_width",
|
||||
"tooltip": "شعاع گسترش برای ناحیه اصلی mask (۰ = بدون گسترش)"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "quadmask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDSampler": {
|
||||
"display_name": "VOIDSampler",
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoise": {
|
||||
"display_name": "VOIDWarpedNoise",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "تعداد فریمهای پیکسلی. برای زوج بودن latent_t گرد به پایین میشود (نیاز patch_size_t=۲)، مثلاً ۴۹ → ۴۵."
|
||||
},
|
||||
"optical_flow": {
|
||||
"name": "optical_flow",
|
||||
"tooltip": "مدل optical flow از OpticalFlowLoader (RAFT-large)."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "خروجی فریمهای ویدیو از مرحله ۱ [T, H, W, ۳]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoiseSource": {
|
||||
"display_name": "VOIDWarpedNoiseSource",
|
||||
"inputs": {
|
||||
"warped_noise": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": "latent نویز تغییرشکلیافته از VOIDWarpedNoise"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VPScheduler": {
|
||||
"display_name": "VPScheduler",
|
||||
"inputs": {
|
||||
@@ -19138,6 +19390,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "مقیاس ویژگیهای صوتی هنگام تزریق به مدل ویدیو."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "نرخ فریم بر ثانیه (fps) محاسبهشده بر اساس طول صوت و تعداد فریمهای ویدیو. در prompt استفاده میشود."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "صوت برای محاسبه تعداد کل فریمهای خروجی و استخراج صوت بخش."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "این بخش کدام است (۰ برای اول، ۱ برای دوم و ...)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "طول این بخش (معمولاً ۱۴۹ فریم)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "دنباله keyframe با padding"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "ماسک نشاندهنده فریمهای معتبر"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "بخش صوتی برای این بخش ویدیو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "صوت برای برش هر بخش خروجی."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "تعداد بخشهای padding که به صورت لیست خروجی داده میشود."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "طول هر بخش (معمولاً ۱۴۹ فریم)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "دنبالههای keyframe با padding"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "ماسکها برای نشان دادن فریمهای معتبر"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "بخش صوتی برای هر بخش ویدیو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "خروجی رمزگذار صوتی"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "خروجی بینایی clip",
|
||||
"tooltip": "بردارهای بینایی CLIP برای اولین فریم."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "خروجی مرجع بینایی clip",
|
||||
"tooltip": "بردارهای بینایی CLIP برای تصویر مرجع."
|
||||
},
|
||||
"height": {
|
||||
"name": "ارتفاع"
|
||||
},
|
||||
"length": {
|
||||
"name": "طول",
|
||||
"tooltip": "تعداد فریمهای ویدئوی تولیدشده. برای WanDancer باید ۱۴۹ باقی بماند."
|
||||
},
|
||||
"mask": {
|
||||
"name": "ماسک",
|
||||
"tooltip": "ماسک شرطیسازی تصویر برای تصویر(ها)ی شروع. سفید حفظ میشود، سیاه تولید میشود. برای تولیدات محلی استفاده میشود."
|
||||
},
|
||||
"negative": {
|
||||
"name": "منفی"
|
||||
},
|
||||
"positive": {
|
||||
"name": "مثبت"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "تصویر شروع",
|
||||
"tooltip": "تصویر(ها)ی اولیه برای رمزگذاری؛ میتواند هر تعداد فریم باشد."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "عرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "مثبت",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "منفی",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "لاتنت",
|
||||
"tooltip": "لاتنت خالی."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
},
|
||||
"login": {
|
||||
"andText": "et",
|
||||
"backToGithubLogin": "S'inscrire avec Github à la place",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"backToSocialLogin": "Inscrivez-vous avec Google ou Github à la place",
|
||||
"confirmPasswordLabel": "Confirmer le mot de passe",
|
||||
@@ -292,7 +293,9 @@
|
||||
"title": "Réauthentification requise"
|
||||
},
|
||||
"signOut": {
|
||||
"saveFailed": "Déconnexion annulée car l’enregistrement de « {workflow} » a échoué.",
|
||||
"signOut": "Se déconnecter",
|
||||
"signOutAnyway": "Se déconnecter quand même",
|
||||
"success": "Déconnexion réussie",
|
||||
"successDetail": "Vous avez été déconnecté de votre compte.",
|
||||
"unsavedChangesMessage": "Vous avez des modifications non enregistrées qui seront perdues si vous vous déconnectez. Voulez-vous continuer ?",
|
||||
@@ -782,6 +785,7 @@
|
||||
"AUDIO_ENCODER": "ENCODEUR_AUDIO",
|
||||
"AUDIO_ENCODER_OUTPUT": "SORTIE_ENCODEUR_AUDIO",
|
||||
"AUDIO_RECORD": "ENREGISTREMENT_AUDIO",
|
||||
"BACKGROUND_REMOVAL": "SUPPRESSION_ARRIÈRE-PLAN",
|
||||
"BOOLEAN": "BOOLEAN",
|
||||
"BOUNDING_BOX": "BOÎTE ENGLOBANTE",
|
||||
"CAMERA_CONTROL": "Contrôle de la caméra",
|
||||
@@ -835,6 +839,7 @@
|
||||
"NOISE": "BRUIT",
|
||||
"OPENAI_CHAT_CONFIG": "CONFIG_CHAT_OPENAI",
|
||||
"OPENAI_INPUT_FILES": "FICHIERS_ENTRÉE_OPENAI",
|
||||
"OPTICAL_FLOW": "OPTICAL_FLOW",
|
||||
"PHOTOMAKER": "PHOTOMAKER",
|
||||
"PIXVERSE_TEMPLATE": "Modèle Pixverse",
|
||||
"POSE_KEYPOINT": "POINT CLÉ DE POSE",
|
||||
@@ -2280,15 +2285,13 @@
|
||||
"Vidu": "Vidu",
|
||||
"Wan": "Wan",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
"_for_testing": "_pour_test",
|
||||
"advanced": "avancé",
|
||||
"animation": "animation",
|
||||
"api": "api",
|
||||
"api node": "nœud api",
|
||||
"attention_experiments": "expériences_d'attention",
|
||||
"audio": "audio",
|
||||
"background removal": "suppression de l’arrière-plan",
|
||||
"batch": "lot",
|
||||
"camera": "caméra",
|
||||
"chroma_radiance": "chroma_radiance",
|
||||
"clip": "clip",
|
||||
"color": "couleur",
|
||||
@@ -2297,7 +2300,6 @@
|
||||
"cond pair": "cond pair",
|
||||
"cond single": "cond unique",
|
||||
"conditioning": "conditionnement",
|
||||
"context": "contexte",
|
||||
"controlnet": "controlnet",
|
||||
"create": "créer",
|
||||
"custom_sampling": "échantillonnage_personnalisé",
|
||||
@@ -2306,6 +2308,7 @@
|
||||
"deprecated": "déprécié",
|
||||
"detection": "détection",
|
||||
"edit_models": "edit_models",
|
||||
"experimental": "expérimental",
|
||||
"flux": "flux",
|
||||
"gligen": "gligen",
|
||||
"guidance": "guidance",
|
||||
@@ -2321,7 +2324,6 @@
|
||||
"lotus": "lotus",
|
||||
"ltxv": "ltxv",
|
||||
"mask": "masque",
|
||||
"math": "math",
|
||||
"model": "modèle",
|
||||
"model_merging": "fusion_de_modèles",
|
||||
"model_patches": "patches_de_modèle",
|
||||
@@ -2338,7 +2340,6 @@
|
||||
"save": "enregistrer",
|
||||
"schedulers": "planificateurs",
|
||||
"scheduling": "planification",
|
||||
"sd": "sd",
|
||||
"sd3": "sd3",
|
||||
"shader": "shader",
|
||||
"sigmas": "sigmas",
|
||||
@@ -2346,7 +2347,6 @@
|
||||
"style_model": "modèle_de_style",
|
||||
"supir": "supir",
|
||||
"text": "texte",
|
||||
"textgen": "textgen",
|
||||
"training": "entraînement",
|
||||
"transform": "transformer",
|
||||
"unet": "unet",
|
||||
@@ -3150,6 +3150,7 @@
|
||||
"deleteFailedTitle": "Échec de la suppression",
|
||||
"deleted": "Flux de travail supprimé",
|
||||
"dirtyClose": "Les fichiers ci-dessous ont été modifiés. Souhaitez-vous les enregistrer avant de fermer ?",
|
||||
"dirtyCloseAnyway": "Fermer quand même",
|
||||
"dirtyCloseHint": "Maintenez Shift pour fermer sans invite",
|
||||
"dirtyCloseTitle": "Enregistrer les modifications ?",
|
||||
"workflowTreeType": {
|
||||
|
||||
@@ -24,6 +24,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ARVideoI2V": {
|
||||
"display_name": "ARVideoI2V",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "taille_du_lot"
|
||||
},
|
||||
"height": {
|
||||
"name": "hauteur"
|
||||
},
|
||||
"length": {
|
||||
"name": "longueur"
|
||||
},
|
||||
"model": {
|
||||
"name": "modèle"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "image_de_départ"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "largeur"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"AddNoise": {
|
||||
"display_name": "AjouterBruit",
|
||||
"inputs": {
|
||||
@@ -4171,6 +4205,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNanoBanana2V2": {
|
||||
"description": "Générez ou modifiez des images de manière synchrone via l'API Google Vertex.",
|
||||
"display_name": "Nano Banana 2",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"model": {
|
||||
"name": "modèle"
|
||||
},
|
||||
"model_aspect_ratio": {
|
||||
"name": "rapport d'aspect"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "résolution"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "niveau de réflexion"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Invite textuelle décrivant l'image à générer ou les modifications à appliquer. Incluez toutes contraintes, styles ou détails que le modèle doit respecter."
|
||||
},
|
||||
"response_modalities": {
|
||||
"name": "modalités de réponse"
|
||||
},
|
||||
"seed": {
|
||||
"name": "graine",
|
||||
"tooltip": "Lorsque la graine est fixée à une valeur spécifique, le modèle s'efforce de fournir la même réponse pour des requêtes répétées. Un résultat déterministe n'est pas garanti. De plus, changer le modèle ou les paramètres, comme la température, peut entraîner des variations de la réponse même avec la même valeur de graine. Par défaut, une valeur de graine aléatoire est utilisée."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "invite système",
|
||||
"tooltip": "Instructions fondamentales qui dictent le comportement de l'IA."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "image de réflexion",
|
||||
"tooltip": "Première image issue du processus de réflexion du modèle. Disponible uniquement avec le niveau de réflexion ÉLEVÉ et la modalité IMAGE+TEXTE."
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNode": {
|
||||
"description": "Génère des réponses textuelles avec le modèle d'IA Gemini de Google. Vous pouvez fournir plusieurs types d'entrées (texte, images, audio, vidéo) comme contexte pour générer des réponses plus pertinentes et significatives.",
|
||||
"display_name": "Google Gemini",
|
||||
@@ -7963,6 +8045,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadBackgroundRemovalModel": {
|
||||
"display_name": "Charger le modèle de suppression d’arrière-plan",
|
||||
"inputs": {
|
||||
"bg_removal_name": {
|
||||
"name": "nom_du_modèle_de_suppression_arrière-plan",
|
||||
"tooltip": "Le modèle utilisé pour supprimer les arrière-plans des images"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "modèle_bg",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadImage": {
|
||||
"display_name": "Charger Image",
|
||||
"inputs": {
|
||||
@@ -11917,6 +12014,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpticalFlowLoader": {
|
||||
"display_name": "Charger le modèle de flux optique",
|
||||
"inputs": {
|
||||
"model_name": {
|
||||
"name": "model_name",
|
||||
"tooltip": "Modèle de flux optique à charger. Les fichiers doivent être placés dans le dossier 'optical_flow'. Actuellement, seul raft_large.pth de torchvision est pris en charge."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"OptimalStepsScheduler": {
|
||||
"display_name": "OptimalStepsScheduler",
|
||||
"inputs": {
|
||||
@@ -13398,6 +13509,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RemoveBackground": {
|
||||
"display_name": "Supprimer l’arrière-plan",
|
||||
"inputs": {
|
||||
"bg_removal_model": {
|
||||
"name": "modèle_de_suppression_arrière-plan",
|
||||
"tooltip": "Modèle de suppression d’arrière-plan utilisé pour générer le masque"
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Image d’entrée dont l’arrière-plan doit être supprimé"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "masque",
|
||||
"tooltip": "Masque de premier plan généré"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenormCFG": {
|
||||
"display_name": "RenormCFG",
|
||||
"inputs": {
|
||||
@@ -17291,7 +17421,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "quad"
|
||||
"name": "quad",
|
||||
"tooltip": "Ce paramètre est obsolète et n'a aucun effet."
|
||||
},
|
||||
"texture": {
|
||||
"name": "texture"
|
||||
@@ -17811,6 +17942,127 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDInpaintConditioning": {
|
||||
"display_name": "VOIDInpaintConditioning",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "Nombre d’images pixels à traiter. Pour CogVideoX-Fun-V1.5 (patch_size_t=2), latent_t doit être pair — les longueurs produisant un latent_t impair sont arrondies à l’inférieur (ex. 49 → 45)."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"quadmask": {
|
||||
"name": "quadmask",
|
||||
"tooltip": "Quadmask prétraité depuis VOIDQuadmaskPreprocess [T, H, L]"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Images vidéo source [T, H, L, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDQuadmaskPreprocess": {
|
||||
"display_name": "VOIDQuadmaskPreprocess",
|
||||
"inputs": {
|
||||
"dilate_width": {
|
||||
"name": "dilate_width",
|
||||
"tooltip": "Rayon de dilatation pour la région principale du masque (0 = pas de dilatation)"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "quadmask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDSampler": {
|
||||
"display_name": "VOIDSampler",
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoise": {
|
||||
"display_name": "VOIDWarpedNoise",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "Nombre d’images pixels. Arrondi à l’inférieur pour rendre latent_t pair (exigence patch_size_t=2), ex. 49 → 45."
|
||||
},
|
||||
"optical_flow": {
|
||||
"name": "optical_flow",
|
||||
"tooltip": "Modèle de flux optique depuis OpticalFlowLoader (RAFT-large)."
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Images vidéo de sortie du passage 1 [T, H, L, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoiseSource": {
|
||||
"display_name": "VOIDWarpedNoiseSource",
|
||||
"inputs": {
|
||||
"warped_noise": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": "Latent de bruit déformé depuis VOIDWarpedNoise"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VPScheduler": {
|
||||
"display_name": "PlanificateurVP",
|
||||
"inputs": {
|
||||
@@ -19138,6 +19390,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "L'échelle des caractéristiques audio lors de leur injection dans le modèle vidéo."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "Le nombre d'images par seconde calculé en fonction de la durée de l'audio et du nombre d'images vidéo. Utilisé dans l'invite."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio pour calculer le nombre total d'images de sortie et extraire l'audio du segment."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "Quel segment est-ce (0 pour le premier, 1 pour le second, etc.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Longueur de ce segment (généralement 149 images)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Séquence de keyframes complétée"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Masque indiquant les images valides"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Segment audio pour ce segment vidéo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio à découper pour chaque segment émis."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "Combien de segments complétés à émettre sous forme de listes."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Longueur de chaque segment (généralement 149 images)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Séquences de keyframes complétées"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Masques indiquant les images valides"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Segment audio pour chaque segment vidéo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "Les embeddings CLIP vision pour la première image."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "Les embeddings CLIP vision pour l’image de référence."
|
||||
},
|
||||
"height": {
|
||||
"name": "hauteur"
|
||||
},
|
||||
"length": {
|
||||
"name": "longueur",
|
||||
"tooltip": "Le nombre d’images dans la vidéo générée. Doit rester à 149 pour WanDancer."
|
||||
},
|
||||
"mask": {
|
||||
"name": "masque",
|
||||
"tooltip": "Masque de conditionnement d’image pour l’image ou les images de départ. Le blanc est conservé, le noir est généré. Utilisé pour les générations locales."
|
||||
},
|
||||
"negative": {
|
||||
"name": "négatif"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positif"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "image_de_départ",
|
||||
"tooltip": "L’image ou les images initiales à encoder, peut contenir n’importe quel nombre d’images."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "largeur"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positif",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "négatif",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "Latent vide."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
},
|
||||
"login": {
|
||||
"andText": "および",
|
||||
"backToGithubLogin": "代わりにGithubでサインアップ",
|
||||
"backToLogin": "ログインに戻る",
|
||||
"backToSocialLogin": "GoogleまたはGithubでサインアップする",
|
||||
"confirmPasswordLabel": "パスワードの確認",
|
||||
@@ -292,7 +293,9 @@
|
||||
"title": "再認証が必要です"
|
||||
},
|
||||
"signOut": {
|
||||
"saveFailed": "「{workflow}」の保存に失敗したため、サインアウトがキャンセルされました。",
|
||||
"signOut": "ログアウト",
|
||||
"signOutAnyway": "とにかくサインアウト",
|
||||
"success": "正常にサインアウトしました",
|
||||
"successDetail": "アカウントからサインアウトしました。",
|
||||
"unsavedChangesMessage": "サインアウトすると未保存の変更が失われます。続行しますか?",
|
||||
@@ -782,6 +785,7 @@
|
||||
"AUDIO_ENCODER": "オーディオエンコーダ",
|
||||
"AUDIO_ENCODER_OUTPUT": "オーディオエンコーダ出力",
|
||||
"AUDIO_RECORD": "オーディオ録音",
|
||||
"BACKGROUND_REMOVAL": "背景除去",
|
||||
"BOOLEAN": "ブール",
|
||||
"BOUNDING_BOX": "バウンディングボックス",
|
||||
"CAMERA_CONTROL": "カメラコントロール",
|
||||
@@ -835,6 +839,7 @@
|
||||
"NOISE": "ノイズ",
|
||||
"OPENAI_CHAT_CONFIG": "OpenAIチャット設定",
|
||||
"OPENAI_INPUT_FILES": "OpenAI入力ファイル",
|
||||
"OPTICAL_FLOW": "OPTICAL_FLOW",
|
||||
"PHOTOMAKER": "PHOTOMAKER",
|
||||
"PIXVERSE_TEMPLATE": "Pixverseテンプレート",
|
||||
"POSE_KEYPOINT": "POSE_KEYPOINT",
|
||||
@@ -2280,15 +2285,13 @@
|
||||
"Vidu": "Vidu",
|
||||
"Wan": "Wan",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
"_for_testing": "_テスト用",
|
||||
"advanced": "高度な機能",
|
||||
"animation": "アニメーション",
|
||||
"api": "API",
|
||||
"api node": "apiノード",
|
||||
"attention_experiments": "アテンション実験",
|
||||
"audio": "オーディオ",
|
||||
"background removal": "背景除去",
|
||||
"batch": "バッチ",
|
||||
"camera": "カメラ",
|
||||
"chroma_radiance": "chroma_radiance",
|
||||
"clip": "クリップ",
|
||||
"color": "カラー",
|
||||
@@ -2297,7 +2300,6 @@
|
||||
"cond pair": "条件ペア",
|
||||
"cond single": "条件単体",
|
||||
"conditioning": "条件付け",
|
||||
"context": "コンテキスト",
|
||||
"controlnet": "コントロールネット",
|
||||
"create": "作成",
|
||||
"custom_sampling": "カスタムサンプリング",
|
||||
@@ -2306,6 +2308,7 @@
|
||||
"deprecated": "非推奨",
|
||||
"detection": "検出",
|
||||
"edit_models": "モデル編集",
|
||||
"experimental": "実験的",
|
||||
"flux": "flux",
|
||||
"gligen": "グライジェン",
|
||||
"guidance": "ガイダンス",
|
||||
@@ -2321,7 +2324,6 @@
|
||||
"lotus": "lotus",
|
||||
"ltxv": "LTXV",
|
||||
"mask": "マスク",
|
||||
"math": "数学",
|
||||
"model": "モデル",
|
||||
"model_merging": "モデルマージ",
|
||||
"model_patches": "モデルパッチ",
|
||||
@@ -2338,7 +2340,6 @@
|
||||
"save": "保存",
|
||||
"schedulers": "スケジューラー",
|
||||
"scheduling": "スケジューリング",
|
||||
"sd": "sd",
|
||||
"sd3": "SD3",
|
||||
"shader": "shader",
|
||||
"sigmas": "シグマ",
|
||||
@@ -2346,7 +2347,6 @@
|
||||
"style_model": "スタイルモデル",
|
||||
"supir": "supir",
|
||||
"text": "テキスト",
|
||||
"textgen": "textgen",
|
||||
"training": "トレーニング",
|
||||
"transform": "変換",
|
||||
"unet": "U-Net",
|
||||
@@ -3150,6 +3150,7 @@
|
||||
"deleteFailedTitle": "削除に失敗しました",
|
||||
"deleted": "ワークフローが削除されました",
|
||||
"dirtyClose": "以下のファイルが変更されました。閉じる前に保存しますか?",
|
||||
"dirtyCloseAnyway": "とにかく閉じる",
|
||||
"dirtyCloseHint": "Shiftキーを押しながら閉じると、プロンプトなしで閉じます",
|
||||
"dirtyCloseTitle": "変更を保存しますか?",
|
||||
"workflowTreeType": {
|
||||
|
||||
@@ -24,6 +24,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ARVideoI2V": {
|
||||
"display_name": "ARVideoI2V",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "バッチサイズ"
|
||||
},
|
||||
"height": {
|
||||
"name": "高さ"
|
||||
},
|
||||
"length": {
|
||||
"name": "長さ"
|
||||
},
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "start_image"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "幅"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"AddNoise": {
|
||||
"display_name": "ノイズを追加",
|
||||
"inputs": {
|
||||
@@ -4171,6 +4205,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNanoBanana2V2": {
|
||||
"description": "Google Vertex API を使用して画像を同期的に生成または編集します。",
|
||||
"display_name": "Nano Banana 2",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "生成後のコントロール"
|
||||
},
|
||||
"model": {
|
||||
"name": "モデル"
|
||||
},
|
||||
"model_aspect_ratio": {
|
||||
"name": "アスペクト比"
|
||||
},
|
||||
"model_resolution": {
|
||||
"name": "解像度"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "思考レベル"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "プロンプト",
|
||||
"tooltip": "生成する画像や適用する編集内容を説明するテキストプロンプト。モデルが従うべき制約、スタイル、詳細も含めてください。"
|
||||
},
|
||||
"response_modalities": {
|
||||
"name": "応答モダリティ"
|
||||
},
|
||||
"seed": {
|
||||
"name": "シード",
|
||||
"tooltip": "シード値を特定の値に固定すると、モデルは繰り返しリクエストに対して同じ応答を返すよう最善を尽くしますが、完全な決定的出力は保証されません。また、モデルやパラメータ設定(例:temperature)を変更すると、同じシード値でも応答が変化する場合があります。デフォルトではランダムなシード値が使用されます。"
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "システムプロンプト",
|
||||
"tooltip": "AI の挙動を指示する基本的な指示文。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "思考画像",
|
||||
"tooltip": "モデルの思考過程から得られる最初の画像。思考レベルがHIGHかつモダリティがIMAGE+TEXTの場合のみ利用可能です。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNode": {
|
||||
"description": "GoogleのGemini AIモデルでテキスト応答を生成します。テキスト、画像、音声、動画など複数の種類の入力をコンテキストとして提供し、より関連性の高い意味のある応答を生成できます。",
|
||||
"display_name": "Google Gemini",
|
||||
@@ -7963,6 +8045,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadBackgroundRemovalModel": {
|
||||
"display_name": "背景除去モデルの読み込み",
|
||||
"inputs": {
|
||||
"bg_removal_name": {
|
||||
"name": "bg_removal_name",
|
||||
"tooltip": "画像から背景を除去するために使用するモデル"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "bg_model",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoadImage": {
|
||||
"display_name": "画像を読み込む",
|
||||
"inputs": {
|
||||
@@ -11917,6 +12014,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpticalFlowLoader": {
|
||||
"display_name": "オプティカルフローモデルの読み込み",
|
||||
"inputs": {
|
||||
"model_name": {
|
||||
"name": "model_name",
|
||||
"tooltip": "読み込むオプティカルフローモデル。ファイルは「optical_flow」フォルダに配置してください。現在はtorchvisionのraft_large.pthのみ対応しています。"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"OptimalStepsScheduler": {
|
||||
"display_name": "OptimalStepsScheduler",
|
||||
"inputs": {
|
||||
@@ -13398,6 +13509,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RemoveBackground": {
|
||||
"display_name": "背景を除去",
|
||||
"inputs": {
|
||||
"bg_removal_model": {
|
||||
"name": "bg_removal_model",
|
||||
"tooltip": "マスクを生成するために使用する背景除去モデル"
|
||||
},
|
||||
"image": {
|
||||
"name": "画像",
|
||||
"tooltip": "背景を除去する入力画像"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "マスク",
|
||||
"tooltip": "生成された前景マスク"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenormCFG": {
|
||||
"display_name": "RenormCFG",
|
||||
"inputs": {
|
||||
@@ -17291,7 +17421,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "quad"
|
||||
"name": "quad",
|
||||
"tooltip": "このパラメータは非推奨であり、何も行いません。"
|
||||
},
|
||||
"texture": {
|
||||
"name": "texture"
|
||||
@@ -17811,6 +17942,127 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDInpaintConditioning": {
|
||||
"display_name": "VOIDInpaintConditioning",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "処理するピクセルフレーム数。CogVideoX-Fun-V1.5(patch_size_t=2)の場合、latent_tは偶数でなければなりません — 奇数のlatent_tになる長さは切り捨てられます(例:49 → 45)。"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"quadmask": {
|
||||
"name": "quadmask",
|
||||
"tooltip": "VOIDQuadmaskPreprocessからの前処理済みquadmask [T, H, W]"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "元動画フレーム [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDQuadmaskPreprocess": {
|
||||
"display_name": "VOIDQuadmaskPreprocess",
|
||||
"inputs": {
|
||||
"dilate_width": {
|
||||
"name": "dilate_width",
|
||||
"tooltip": "主マスク領域の膨張半径(0=膨張なし)"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "quadmask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDSampler": {
|
||||
"display_name": "VOIDSampler",
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoise": {
|
||||
"display_name": "VOIDWarpedNoise",
|
||||
"inputs": {
|
||||
"batch_size": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "ピクセルフレーム数。latent_tを偶数にするため切り捨てられます(patch_size_t=2の要件、例:49 → 45)。"
|
||||
},
|
||||
"optical_flow": {
|
||||
"name": "optical_flow",
|
||||
"tooltip": "OpticalFlowLoader(RAFT-large)からのオプティカルフローモデル。"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "パス1の出力動画フレーム [T, H, W, 3]"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VOIDWarpedNoiseSource": {
|
||||
"display_name": "VOIDWarpedNoiseSource",
|
||||
"inputs": {
|
||||
"warped_noise": {
|
||||
"name": "warped_noise",
|
||||
"tooltip": "VOIDWarpedNoiseからのワープノイズlatent"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VPScheduler": {
|
||||
"display_name": "VPスケジューラー",
|
||||
"inputs": {
|
||||
@@ -19138,6 +19390,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "オーディオ特徴量をビデオモデルに注入する際のスケール。"
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "オーディオの長さとビデオフレーム数から計算されたfps。プロンプトで使用されます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "出力フレーム総数の計算やセグメントオーディオの抽出に使用するオーディオ。"
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "このセグメントがどれか(最初は0、次は1など)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "このセグメントの長さ(通常は149フレーム)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "パディングされたキーフレームシーケンス"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "有効なフレームを示すマスク"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "このビデオセグメント用のオーディオセグメント"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "各出力セグメント用にスライスするオーディオ。"
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "リストとして出力するパディング済みセグメントの数。"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "各セグメントの長さ(通常は149フレーム)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "パディングされたキーフレームシーケンス"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "有効なフレームを示すマスク"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "各ビデオセグメント用のオーディオセグメント"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "最初のフレームのCLIP vision埋め込み。"
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "参照画像のCLIP vision埋め込み。"
|
||||
},
|
||||
"height": {
|
||||
"name": "高さ"
|
||||
},
|
||||
"length": {
|
||||
"name": "長さ",
|
||||
"tooltip": "生成される動画のフレーム数。WanDancerの場合は149のままにしてください。"
|
||||
},
|
||||
"mask": {
|
||||
"name": "マスク",
|
||||
"tooltip": "開始画像の画像処理用マスク。白は保持、黒は生成されます。ローカル生成に使用されます。"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "開始画像",
|
||||
"tooltip": "エンコードする初期画像。任意のフレーム数を指定できます。"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "幅"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "空のlatent。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
},
|
||||
"login": {
|
||||
"andText": "및",
|
||||
"backToGithubLogin": "대신 Github로 가입하기",
|
||||
"backToLogin": "로그인으로 돌아가기",
|
||||
"backToSocialLogin": "Google 또는 Github로 가입하기",
|
||||
"confirmPasswordLabel": "비밀번호 확인",
|
||||
@@ -292,7 +293,9 @@
|
||||
"title": "재인증 필요"
|
||||
},
|
||||
"signOut": {
|
||||
"saveFailed": "\"{workflow}\" 저장에 실패하여 로그아웃이 취소되었습니다.",
|
||||
"signOut": "로그아웃",
|
||||
"signOutAnyway": "그래도 로그아웃하기",
|
||||
"success": "성공적으로 로그아웃되었습니다",
|
||||
"successDetail": "계정에서 로그아웃되었습니다.",
|
||||
"unsavedChangesMessage": "저장되지 않은 변경 사항이 있습니다. 로그아웃하면 변경 사항이 사라집니다. 계속하시겠습니까?",
|
||||
@@ -782,6 +785,7 @@
|
||||
"AUDIO_ENCODER": "AUDIO_ENCODER",
|
||||
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
|
||||
"AUDIO_RECORD": "AUDIO_RECORD",
|
||||
"BACKGROUND_REMOVAL": "배경 제거",
|
||||
"BOOLEAN": "논리값",
|
||||
"BOUNDING_BOX": "BOUNDING_BOX",
|
||||
"CAMERA_CONTROL": "카메라 제어",
|
||||
@@ -835,6 +839,7 @@
|
||||
"NOISE": "노이즈",
|
||||
"OPENAI_CHAT_CONFIG": "OpenAI 채팅 설정",
|
||||
"OPENAI_INPUT_FILES": "OpenAI 입력 파일",
|
||||
"OPTICAL_FLOW": "OPTICAL_FLOW",
|
||||
"PHOTOMAKER": "PHOTOMAKER",
|
||||
"PIXVERSE_TEMPLATE": "Pixverse 템플릿",
|
||||
"POSE_KEYPOINT": "POSE_KEYPOINT",
|
||||
@@ -2280,15 +2285,13 @@
|
||||
"Vidu": "Vidu",
|
||||
"Wan": "Wan",
|
||||
"WaveSpeed": "WaveSpeed",
|
||||
"_for_testing": "_테스트용",
|
||||
"advanced": "고급",
|
||||
"animation": "애니메이션",
|
||||
"api": "API",
|
||||
"api node": "api 노드",
|
||||
"attention_experiments": "어텐션 실험",
|
||||
"audio": "오디오",
|
||||
"background removal": "배경 제거",
|
||||
"batch": "배치",
|
||||
"camera": "카메라",
|
||||
"chroma_radiance": "chroma_radiance",
|
||||
"clip": "클립",
|
||||
"color": "색상",
|
||||
@@ -2297,7 +2300,6 @@
|
||||
"cond pair": "조건 쌍",
|
||||
"cond single": "단일 조건",
|
||||
"conditioning": "조건화",
|
||||
"context": "컨텍스트",
|
||||
"controlnet": "컨트롤넷",
|
||||
"create": "생성",
|
||||
"custom_sampling": "사용자 정의 샘플링",
|
||||
@@ -2306,6 +2308,7 @@
|
||||
"deprecated": "지원 중단",
|
||||
"detection": "감지",
|
||||
"edit_models": "edit_models",
|
||||
"experimental": "실험적",
|
||||
"flux": "flux",
|
||||
"gligen": "글리젠",
|
||||
"guidance": "가이드",
|
||||
@@ -2321,7 +2324,6 @@
|
||||
"lotus": "lotus",
|
||||
"ltxv": "ltxv",
|
||||
"mask": "마스크",
|
||||
"math": "수학",
|
||||
"model": "모델",
|
||||
"model_merging": "모델 병합",
|
||||
"model_patches": "모델 패치",
|
||||
@@ -2338,7 +2340,6 @@
|
||||
"save": "저장",
|
||||
"schedulers": "스케줄러",
|
||||
"scheduling": "스케줄링",
|
||||
"sd": "sd",
|
||||
"sd3": "sd3",
|
||||
"shader": "shader",
|
||||
"sigmas": "시그마",
|
||||
@@ -2346,7 +2347,6 @@
|
||||
"style_model": "스타일 모델",
|
||||
"supir": "supir",
|
||||
"text": "텍스트",
|
||||
"textgen": "textgen",
|
||||
"training": "학습",
|
||||
"transform": "변환",
|
||||
"unet": "UNet",
|
||||
@@ -3150,6 +3150,7 @@
|
||||
"deleteFailedTitle": "삭제 실패",
|
||||
"deleted": "워크플로가 삭제되었습니다.",
|
||||
"dirtyClose": "아래 파일들이 변경되었습니다. 닫기 전에 저장하시겠습니까?",
|
||||
"dirtyCloseAnyway": "그래도 닫기",
|
||||
"dirtyCloseHint": "프롬프트 없이 닫으려면 Shift를 누르세요",
|
||||
"dirtyCloseTitle": "변경 사항 저장",
|
||||
"workflowTreeType": {
|
||||
|
||||