Compare commits

..

2 Commits

Author SHA1 Message Date
jaeone94
160c615bc4 fix(i18n): add g.inSubgraph locale key 2026-02-25 22:36:01 +09:00
jaeone94
eb61c0bb4d refactor: improve missing node error handling and add roadmap documentation
- App & WorkflowService: Document the temporary coexistence of the Missing Nodes Modal and Errors Tab, noting that the modal will be removed once Node Replacement is implemented.
- Error Handling: Collect `cnr_id` and `execution_id` when processing missing nodes to provide sufficient context for the Errors Tab.
- ExecutionErrorStore: Enforce strict `NodeExecutionId` typing in `applyNodeError`.
- Clean up obsolete comments and reorganize imports across error stores and app script.
2026-02-25 22:36:00 +09:00
125 changed files with 580 additions and 5857 deletions

View File

@@ -1,110 +0,0 @@
name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
perf-tests:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1
- name: Upload perf metrics
if: always()
uses: actions/upload-artifact@v6
with:
name: perf-metrics
path: test-results/perf-metrics.json
retention-days: 30
if-no-files-found: warn
report:
needs: perf-tests
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
- name: Download PR perf metrics
continue-on-error: true
uses: actions/download-artifact@v7
with:
name: perf-metrics
path: test-results/
- name: Download baseline perf metrics
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ github.event.pull_request.base.ref }}
workflow: ci-perf-report.yaml
event: push
name: perf-metrics
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Generate perf report
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
- name: Read perf report
id: perf-report
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./perf-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
body: |
${{ steps.perf-report.outputs.content }}
<!-- COMFYUI_FRONTEND_PERF -->
body-include: '<!-- COMFYUI_FRONTEND_PERF -->'

View File

@@ -24,7 +24,6 @@ import {
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
@@ -186,7 +185,6 @@ export class ComfyPage {
public readonly dragDrop: DragDropHelper
public readonly command: CommandHelper
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
/** Worker index to test user ID */
public readonly userIds: string[] = []
@@ -231,7 +229,6 @@ export class ComfyPage {
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
this.command = new CommandHelper(page)
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
}
get visibleToasts() {
@@ -439,13 +436,7 @@ export const comfyPageFixture = base.extend<{
}
await comfyPage.setup()
const isPerf = testInfo.tags.includes('@perf')
if (isPerf) await comfyPage.perf.init()
await use(comfyPage)
if (isPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {
const comfyMouse = new ComfyMouse(comfyPage)

View File

@@ -1,96 +0,0 @@
import type { CDPSession, Page } from '@playwright/test'
interface PerfSnapshot {
RecalcStyleCount: number
RecalcStyleDuration: number
LayoutCount: number
LayoutDuration: number
TaskDuration: number
JSHeapUsedSize: number
Timestamp: number
}
export interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
export class PerformanceHelper {
private cdp: CDPSession | null = null
private snapshot: PerfSnapshot | null = null
constructor(private readonly page: Page) {}
async init(): Promise<void> {
this.cdp = await this.page.context().newCDPSession(this.page)
await this.cdp.send('Performance.enable')
}
async dispose(): Promise<void> {
this.snapshot = null
if (this.cdp) {
try {
await this.cdp.send('Performance.disable')
} finally {
await this.cdp.detach()
this.cdp = null
}
}
}
private async getSnapshot(): Promise<PerfSnapshot> {
if (!this.cdp) throw new Error('PerformanceHelper not initialized')
const { metrics } = (await this.cdp.send('Performance.getMetrics')) as {
metrics: { name: string; value: number }[]
}
function get(name: string): number {
return metrics.find((m) => m.name === name)?.value ?? 0
}
return {
RecalcStyleCount: get('RecalcStyleCount'),
RecalcStyleDuration: get('RecalcStyleDuration'),
LayoutCount: get('LayoutCount'),
LayoutDuration: get('LayoutDuration'),
TaskDuration: get('TaskDuration'),
JSHeapUsedSize: get('JSHeapUsedSize'),
Timestamp: get('Timestamp')
}
}
async startMeasuring(): Promise<void> {
if (this.snapshot) {
throw new Error(
'Measurement already in progress — call stopMeasuring() first'
)
}
this.snapshot = await this.getSnapshot()
}
async stopMeasuring(name: string): Promise<PerfMeasurement> {
if (!this.snapshot) throw new Error('Call startMeasuring() first')
const after = await this.getSnapshot()
const before = this.snapshot
this.snapshot = null
function delta(key: keyof PerfSnapshot): number {
return after[key] - before[key]
}
return {
name,
durationMs: delta('Timestamp') * 1000,
styleRecalcs: delta('RecalcStyleCount'),
styleRecalcDurationMs: delta('RecalcStyleDuration') * 1000,
layouts: delta('LayoutCount'),
layoutDurationMs: delta('LayoutDuration') * 1000,
taskDurationMs: delta('TaskDuration') * 1000,
heapDeltaBytes: delta('JSHeapUsedSize')
}
}
}

View File

@@ -1,14 +1,11 @@
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { writePerfReport } from './helpers/perfReporter'
import { restorePath } from './utils/backupUtils'
dotenv.config()
export default function globalTeardown(_config: FullConfig) {
writePerfReport()
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])

View File

@@ -1,49 +0,0 @@
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
export interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const TEMP_DIR = join('test-results', 'perf-temp')
export function recordMeasurement(m: PerfMeasurement) {
mkdirSync(TEMP_DIR, { recursive: true })
const filename = `${m.name}-${Date.now()}.json`
writeFileSync(join(TEMP_DIR, filename), JSON.stringify(m))
}
export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
let tempFiles: string[]
try {
tempFiles = readdirSync(TEMP_DIR).filter((f) => f.endsWith('.json'))
} catch {
return
}
if (tempFiles.length === 0) return
const measurements: PerfMeasurement[] = tempFiles.map((f) =>
JSON.parse(readFileSync(join(TEMP_DIR, f), 'utf-8'))
)
const report: PerfReport = {
timestamp: new Date().toISOString(),
gitSha,
branch,
measurements
}
writeFileSync(
join('test-results', 'perf-metrics.json'),
JSON.stringify(report, null, 2)
)
}

View File

@@ -1,70 +0,0 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { recordMeasurement } from '../helpers/perfReporter'
test.describe('Performance', { tag: ['@perf'] }, () => {
test('canvas idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Let the canvas idle for 2 seconds — no user interaction.
// Measures baseline style recalcs from reactive state + render loop.
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('canvas-idle')
recordMeasurement(m)
console.log(
`Canvas idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('canvas mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Sweep mouse across the canvas — crosses nodes, empty space, slots
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('canvas-mouse-sweep')
recordMeasurement(m)
console.log(
`Mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('DOM widget clipping during node selection', async ({ comfyPage }) => {
// Load default workflow which has DOM widgets (text inputs, combos)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.perf.startMeasuring()
// Select and deselect nodes rapidly to trigger clipping recalculation
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
// Click on canvas area (nodes occupy various positions)
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('dom-widget-clipping')
recordMeasurement(m)
console.log(`Clipping: ${m.layouts} forced layouts`)
})
})

View File

@@ -22,9 +22,7 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
__COMFYUI_FRONTEND_VERSION__: 'readonly'
} as const
const settings = {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.6",
"version": "1.41.5",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -36,18 +36,7 @@ export default defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
timeout: 15000,
grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf
},
{
name: 'performance',
use: {
...devices['Desktop Chrome'],
trace: 'retain-on-failure'
},
timeout: 60_000,
grep: /@perf/,
fullyParallel: false
grepInvert: /@mobile/ // Run all tests except those tagged with @mobile
},
{

View File

@@ -1,125 +0,0 @@
import { existsSync, readFileSync } from 'node:fs'
interface PerfMeasurement {
name: string
durationMs: number
styleRecalcs: number
styleRecalcDurationMs: number
layouts: number
layoutDurationMs: number
taskDurationMs: number
heapDeltaBytes: number
}
interface PerfReport {
timestamp: string
gitSha: string
branch: string
measurements: PerfMeasurement[]
}
const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
function formatDelta(pct: number): string {
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
return `${pct.toFixed(0)}% 🟢`
}
function formatBytes(bytes: number): string {
if (Math.abs(bytes) < 1024) return `${bytes} B`
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function calcDelta(
baseline: number,
current: number
): { pct: number; isNew: boolean } {
if (baseline > 0) {
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
}
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
}
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
}
function main() {
if (!existsSync(CURRENT_PATH)) {
process.stdout.write(
'## ⚡ Performance Report\n\nNo perf metrics found. Perf tests may not have run.\n'
)
process.exit(0)
}
const current: PerfReport = JSON.parse(readFileSync(CURRENT_PATH, 'utf-8'))
const baseline: PerfReport | null = existsSync(BASELINE_PATH)
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
: null
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
if (baseline) {
lines.push(
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const m of current.measurements) {
const base = baseline.measurements.find((b) => b.name === m.name)
if (!base) {
lines.push(`| ${m.name}: style recalcs | — | ${m.styleRecalcs} | new |`)
lines.push(`| ${m.name}: layouts | — | ${m.layouts} | new |`)
lines.push(
`| ${m.name}: task duration | — | ${m.taskDurationMs.toFixed(0)}ms | new |`
)
continue
}
const recalcDelta = calcDelta(base.styleRecalcs, m.styleRecalcs)
lines.push(
`| ${m.name}: style recalcs | ${base.styleRecalcs} | ${m.styleRecalcs} | ${formatDeltaCell(recalcDelta)} |`
)
const layoutDelta = calcDelta(base.layouts, m.layouts)
lines.push(
`| ${m.name}: layouts | ${base.layouts} | ${m.layouts} | ${formatDeltaCell(layoutDelta)} |`
)
const taskDelta = calcDelta(base.taskDurationMs, m.taskDurationMs)
lines.push(
`| ${m.name}: task duration | ${base.taskDurationMs.toFixed(0)}ms | ${m.taskDurationMs.toFixed(0)}ms | ${formatDeltaCell(taskDelta)} |`
)
}
} else {
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const m of current.measurements) {
lines.push(`| ${m.name}: style recalcs | ${m.styleRecalcs} |`)
lines.push(`| ${m.name}: layouts | ${m.layouts} |`)
lines.push(
`| ${m.name}: task duration | ${m.taskDurationMs.toFixed(0)}ms |`
)
lines.push(`| ${m.name}: heap delta | ${formatBytes(m.heapDeltaBytes)} |`)
}
}
lines.push('\n<details><summary>Raw data</summary>\n')
lines.push('```json')
lines.push(JSON.stringify(current, null, 2))
lines.push('```')
lines.push('\n</details>')
process.stdout.write(lines.join('\n') + '\n')
}
main()

View File

@@ -51,6 +51,7 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }

View File

@@ -25,7 +25,7 @@
<!-- First panel: sidebar when left, properties when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
"
:class="
sidebarLocation === 'left'
@@ -85,7 +85,7 @@
<!-- Last panel: properties when left, sidebar when right -->
<SplitterPanel
v-if="
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
"
:class="
sidebarLocation === 'right'
@@ -124,7 +124,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -145,13 +144,9 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const appModeStore = useAppModeStore()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)

View File

@@ -262,7 +262,7 @@ describe('TopMenuSection', () => {
)
})
it('opens the job history sidebar tab when QPO V2 is enabled', async () => {
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -273,10 +273,10 @@ describe('TopMenuSection', () => {
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
})
it('toggles the job history sidebar tab when QPO V2 is enabled', async () => {
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
@@ -287,7 +287,7 @@ describe('TopMenuSection', () => {
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)

View File

@@ -62,7 +62,7 @@
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
@@ -127,15 +127,13 @@
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
>
<QueueInlineProgressSummary
:hidden="shouldHideInlineProgressSummary"
/>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="shouldHideInlineProgressSummary"
:hidden="isQueueOverlayExpanded"
/>
<QueueNotificationBannerHost
v-if="shouldShowQueueNotificationBanners"
@@ -243,9 +241,6 @@ const inlineProgressSummaryTarget = computed(() => {
}
return progressTarget.value
})
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
@@ -288,7 +283,7 @@ onMounted(() => {
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')

View File

@@ -47,7 +47,7 @@
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="shouldHideInlineProgress"
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
@@ -287,9 +287,6 @@ const inlineProgressTarget = computed(() => {
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
const shouldHideInlineProgress = computed(
() => !isQueuePanelV2Enabled.value && queueOverlayExpanded
)
watch(
panelElement,
(target) => {

View File

@@ -1,324 +0,0 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
type BoundStyle = { top: string; left: string; width: string; height: string }
const appModeStore = useAppModeStore()
const canvasInteractions = useCanvasInteractions()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const hoveringSelectable = ref(false)
workflowStore.activeWorkflow?.changeTracker?.reset()
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return { nodeId, widgetName }
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
const { graph } = canvas
if (!canvas || !graph) return
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (!e) return
canvas.adjustMouseEvent(e)
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
if (!node) return
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
const height =
(widget.computedHeight !== undefined
? widget.computedHeight - 4
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
return {
width: `${node.size[0] - marginX * 2}px`,
height: `${height}px`,
left: `${node.pos[0] + marginX}px`,
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
}
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
}
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
}
function nodeToDisplayTuple(
n: LGraphNode
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
)
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{ t('linearMode.builder.title') }}
<Button class="ml-auto" @click="appModeStore.exitBuilder">
{{ t('linearMode.builder.exit') }}
</Button>
</div>
<PropertiesAccordionItem
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId === id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'bg-warning-background/40 p-2 my-2 rounded-lg',
index === 0 && 'ring-warning-background ring-2'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport to="body">
<div
:class="
cn(
'absolute w-full h-full pointer-events-auto',
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
)
"
@pointerdown="handleDown"
@pointermove="hoveringSelectable = !!getHovered($event)"
@click="handleClick"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
/>
</div>
</div>
</TransformPane>
</div>
</Teleport>
</template>

View File

@@ -62,7 +62,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useEventListener } from '@vueuse/core'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
@@ -76,14 +75,6 @@ import type { BuilderToolbarStep } from './types'
const { t } = useI18n()
const appModeStore = useAppModeStore()
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
void appModeStore.exitBuilder()
})
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const { rename, remove } = defineProps<{
title: string
subTitle?: string
rename?: () => void
remove?: () => void
}>()
const entries = computed(() => {
const items = []
if (rename)
items.push({
label: t('g.rename'),
command: rename,
icon: 'icon-[lucide--pencil]'
})
if (remove)
items.push({
label: t('g.delete'),
command: remove,
icon: 'icon-[lucide--trash-2]'
})
return items
})
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto" v-text="title" />
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
</template>

View File

@@ -33,10 +33,12 @@ export function useBuilderSave() {
return
}
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
if (!workflow.isTemporary) {
try {
workflow.changeTracker?.checkState()
appModeStore.saveSelectedToWorkflow()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
@@ -73,7 +75,6 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.saveSelectedToWorkflow()
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp

View File

@@ -1,59 +0,0 @@
<script setup lang="ts" generic="T">
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
import { DraggableList } from '@/scripts/ui/draggableList'
const modelValue = defineModel<T[]>({ required: true })
const draggableList = ref<DraggableList>()
const draggableItems = useTemplateRef('draggableItems')
watchPostEffect(() => {
void modelValue.value.length
draggableList.value?.dispose()
if (!draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const itemList = modelValue.value
const [item] = itemList.splice(oldPosition, 1)
itemList.splice(newPosition, 0, item)
modelValue.value = [...itemList]
}
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<slot
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
/>
</div>
</template>

View File

@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'

View File

@@ -77,8 +77,7 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import { histogramToPath } from './curveUtils'

View File

@@ -3,7 +3,7 @@
</template>
<script setup lang="ts">
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import CurveEditor from './CurveEditor.vue'

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
import {
createMonotoneInterpolator,

View File

@@ -1,4 +1,4 @@
import type { CurvePoint } from './types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
/**
* Monotone cubic Hermite interpolation.
@@ -95,15 +95,15 @@ export function histogramToPath(histogram: Uint32Array): string {
const max = sorted[Math.floor(255 * 0.995)]
if (max === 0) return ''
const invMax = 1 / max
const parts: string[] = ['M0,1']
const step = 1 / 255
let d = 'M0,1'
for (let i = 0; i < 256; i++) {
const x = i / 255
const y = 1 - Math.min(1, histogram[i] * invMax)
parts.push(`L${x},${y}`)
const x = i * step
const y = 1 - Math.min(1, histogram[i] / max)
d += ` L${x.toFixed(4)},${y.toFixed(4)}`
}
parts.push('L1,1 Z')
return parts.join(' ')
d += ' L1,1 Z'
return d
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {

View File

@@ -1 +0,0 @@
export type CurvePoint = [x: number, y: number]

View File

@@ -438,6 +438,7 @@ onMounted(() => {
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]

View File

@@ -38,8 +38,7 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
@@ -127,7 +126,6 @@ import {
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'

View File

@@ -1,359 +0,0 @@
<template>
<div
class="widget-expands flex h-full w-full flex-col gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<div
class="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-lg bg-node-component-surface"
>
<div class="relative max-h-full w-full" :style="canvasContainerStyle">
<img
v-if="inputImageUrl"
:src="inputImageUrl"
class="absolute inset-0 size-full"
draggable="false"
@load="handleInputImageLoad"
@dragstart.prevent
/>
<canvas
ref="canvasEl"
class="absolute inset-0 size-full cursor-none touch-none"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerenter="handlePointerEnter"
@pointerleave="handlePointerLeave"
/>
<div
v-show="cursorVisible"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
:style="cursorStyle"
/>
</div>
</div>
<div
v-if="isImageInputConnected"
class="text-center text-xs text-muted-foreground"
>
{{ canvasWidth }} x {{ canvasHeight }}
</div>
<div
ref="controlsEl"
:class="
cn(
'grid shrink-0 gap-x-1 gap-y-1',
compact ? 'grid-cols-1' : 'grid-cols-[auto_1fr]'
)
"
>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.tool') }}
</div>
<div
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
tool === PAINTER_TOOLS.BRUSH
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="tool = PAINTER_TOOLS.BRUSH"
>
{{ $t('painter.brush') }}
</Button>
<Button
variant="textonly"
size="unset"
:class="
cn(
'flex-1 self-stretch px-2 text-xs transition-colors',
tool === PAINTER_TOOLS.ERASER
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
: 'text-node-text-muted hover:text-node-text'
)
"
@click="tool = PAINTER_TOOLS.ERASER"
>
{{ $t('painter.eraser') }}
</Button>
</div>
<div
v-if="!compact"
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.size') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[brushSize]"
:min="1"
:max="200"
:step="1"
class="flex-1"
@update:model-value="(v) => v?.length && (brushSize = v[0])"
/>
<span class="w-8 text-center text-xs text-node-text-muted">{{
brushSize
}}</span>
</div>
<template v-if="tool === PAINTER_TOOLS.BRUSH">
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.color') }}
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
>
<input
type="color"
:value="brushColorDisplay"
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
@input="
(e) => (brushColorDisplay = (e.target as HTMLInputElement).value)
"
/>
<span class="min-w-[4ch] truncate text-xs">{{
brushColorDisplay
}}</span>
<span class="ml-auto flex items-center text-xs text-node-text-muted">
<input
type="number"
:value="brushOpacityPercent"
min="0"
max="100"
step="1"
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
@click.prevent
@change="
(e) => {
const val = Math.min(
100,
Math.max(0, Number((e.target as HTMLInputElement).value))
)
brushOpacityPercent = val
;(e.target as HTMLInputElement).value = String(val)
}
"
/>%</span
>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.hardness') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[brushHardnessPercent]"
:min="0"
:max="100"
:step="1"
class="flex-1"
@update:model-value="
(v) => v?.length && (brushHardnessPercent = v[0])
"
/>
<span class="w-8 text-center text-xs text-node-text-muted"
>{{ brushHardnessPercent }}%</span
>
</div>
</template>
<template v-if="!isImageInputConnected">
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.width') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[canvasWidth]"
:min="64"
:max="4096"
:step="64"
class="flex-1"
@update:model-value="(v) => v?.length && (canvasWidth = v[0])"
/>
<span class="w-10 text-center text-xs text-node-text-muted">{{
canvasWidth
}}</span>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.height') }}
</div>
<div
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
>
<Slider
:model-value="[canvasHeight]"
:min="64"
:max="4096"
:step="64"
class="flex-1"
@update:model-value="(v) => v?.length && (canvasHeight = v[0])"
/>
<span class="w-10 text-center text-xs text-node-text-muted">{{
canvasHeight
}}</span>
</div>
<div
class="flex w-28 items-center truncate text-sm text-muted-foreground"
>
{{ $t('painter.background') }}
</div>
<div
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
>
<input
type="color"
:value="backgroundColorDisplay"
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
@input="
(e) =>
(backgroundColorDisplay = (e.target as HTMLInputElement).value)
"
/>
<span class="min-w-[4ch] truncate text-xs">{{
backgroundColorDisplay
}}</span>
</div>
</template>
<Button
variant="secondary"
size="md"
:class="
cn(
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',
!compact && 'col-span-2'
)
"
@click="handleClear"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('painter.clear') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { computed, useTemplateRef } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import { PAINTER_TOOLS, usePainter } from '@/composables/painter/usePainter'
import { toHexFromFormat } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
const { nodeId } = defineProps<{
nodeId: string
}>()
const modelValue = defineModel<string>({ default: '' })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
const { width: controlsWidth } = useElementSize(controlsEl)
const compact = computed(
() => controlsWidth.value > 0 && controlsWidth.value < 350
)
const {
tool,
brushSize,
brushColor,
brushOpacity,
brushHardness,
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
isImageInputConnected,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerEnter,
handlePointerLeave,
handleInputImageLoad,
handleClear
} = usePainter(nodeId, { canvasEl, modelValue })
const canvasContainerStyle = computed(() => ({
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
backgroundColor: isImageInputConnected.value
? undefined
: backgroundColor.value
}))
const cursorStyle = computed(() => {
const size = displayBrushSize.value
const x = cursorX.value - size / 2
const y = cursorY.value - size / 2
return {
width: `${size}px`,
height: `${size}px`,
transform: `translate(${x}px, ${y}px)`
}
})
const brushOpacityPercent = computed({
get: () => Math.round(brushOpacity.value * 100),
set: (val: number) => {
brushOpacity.value = val / 100
}
})
const brushHardnessPercent = computed({
get: () => Math.round(brushHardness.value * 100),
set: (val: number) => {
brushHardness.value = val / 100
}
})
const brushColorDisplay = computed({
get: () => toHexFromFormat(brushColor.value, 'hex'),
set: (val: unknown) => {
brushColor.value = toHexFromFormat(val, 'hex')
}
})
const backgroundColorDisplay = computed({
get: () => toHexFromFormat(backgroundColor.value, 'hex'),
set: (val: unknown) => {
backgroundColor.value = toHexFromFormat(val, 'hex')
}
})
</script>

View File

@@ -1,146 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
vi.mock('vue-i18n', () => {
return {
createI18n: () => ({
global: {
t: (key: string) => key,
te: () => true,
d: (value: string) => value
}
}),
useI18n: () => ({
t: (key: string) => key
})
}
})
const createResultItem = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
priority: 0
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
...overrides
})
const mountJobAssetsList = (jobs: JobListItem[]) => {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]
return mount(JobAssetsList, {
props: { displayedJobGroups }
})
}
describe('JobAssetsList', () => {
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
listItem.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
expect(listItem.props('previewUrl')).toBe('/api/view/job-1.webm')
expect(listItem.props('isVideoPreview')).toBe(true)
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.find('i').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
})
const wrapper = mountJobAssetsList([job])
const listItem = wrapper.findComponent({ name: 'AssetsListItem' })
await listItem.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('viewItem')).toBeUndefined()
})
})

View File

@@ -12,8 +12,7 @@
v-for="job in group.items"
:key="job.id"
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
@@ -24,8 +23,6 @@
@mouseenter="hoveredJobId = job.id"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
@@ -81,7 +78,7 @@ import { isActiveJobState } from '@/utils/queueUtil'
defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
@@ -103,28 +100,6 @@ const isCancelable = (job: JobListItem) =>
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
const getPreviewOutput = (job: JobListItem) => job.taskRef?.previewOutput
const getJobPreviewUrl = (job: JobListItem) => {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
return preview.url
}
return job.iconImageUrl
}
const isVideoPreviewJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
const isPreviewableCompletedJob = (job: JobListItem) =>
job.state === 'completed' && !!getPreviewOutput(job)
const emitViewItem = (job: JobListItem) => {
if (isPreviewableCompletedJob(job)) {
emit('viewItem', job)
}
}
const getJobIconClass = (job: JobListItem): string | undefined => {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {

View File

@@ -131,11 +131,11 @@ export const Queued: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-1'
const priority = 104
const queueIndex = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 90_000)
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(
@@ -179,13 +179,13 @@ export const QueuedParallel: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-parallel'
const priority = 210
const queueIndex = 210
// Current job in pending with some ahead
queue.pendingTasks = [
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
makePendingTask(jobId, priority, Date.now() - 120_000)
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
]
// Seen 2 minutes ago - set via prompt metadata above
@@ -238,9 +238,9 @@ export const Running: Story = {
const exec = useExecutionStore()
const jobId = 'job-running-1'
const priority = 300
const queueIndex = 300
queue.runningTasks = [
makeRunningTask(jobId, priority, Date.now() - 65_000)
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
]
queue.historyTasks = [
makeHistoryTask('hist-r1', 250, 30, true),
@@ -279,10 +279,10 @@ export const QueuedZeroAheadSingleRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-single'
const priority = 510
const queueIndex = 510
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 45_000)
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
]
queue.historyTasks = [
@@ -324,10 +324,10 @@ export const QueuedZeroAheadMultiRunning: Story = {
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-multi'
const priority = 520
const queueIndex = 520
queue.pendingTasks = [
makePendingTask(jobId, priority, Date.now() - 20_000)
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
]
queue.historyTasks = [
@@ -380,8 +380,8 @@ export const Completed: Story = {
const queue = useQueueStore()
const jobId = 'job-completed-1'
const priority = 400
queue.historyTasks = [makeHistoryTask(jobId, priority, 37, true)]
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
return { args: { ...args, jobId } }
},
@@ -401,11 +401,11 @@ export const Failed: Story = {
const queue = useQueueStore()
const jobId = 'job-failed-1'
const priority = 410
const queueIndex = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
priority,
queueIndex,
12,
false,
'Example error: invalid inputs for node X'

View File

@@ -168,14 +168,14 @@ const queuedAtValue = computed(() =>
const currentQueueIndex = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.job.priority) : null
return task ? Number(task.queueIndex) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.job.priority) < idx
(t: TaskItemImpl) => Number(t.queueIndex) < idx
)
return ahead.length
})

View File

@@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import RangeEditor from './RangeEditor.vue'
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
function mountEditor(props: InstanceType<typeof RangeEditor>['$props']) {
return mount(RangeEditor, {
props,
global: { plugins: [i18n] }
})
}
describe('RangeEditor', () => {
it('renders with min and max handles', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
expect(wrapper.find('svg').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-min"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="handle-max"]').exists()).toBe(true)
})
it('highlights selected range in plain mode', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
const highlight = wrapper.find('[data-testid="range-highlight"]')
expect(highlight.attributes('x')).toBe('0.2')
expect(highlight.attributes('width')).toBe('0.6000000000000001')
})
it('dims area outside the range in histogram mode', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
modelValue: { min: 0.2, max: 0.8 },
display: 'histogram',
histogram
})
const left = wrapper.find('[data-testid="range-dim-left"]')
const right = wrapper.find('[data-testid="range-dim-right"]')
expect(left.attributes('width')).toBe('0.2')
expect(right.attributes('x')).toBe('0.8')
})
it('hides midpoint handle by default', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 }
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(false)
})
it('shows midpoint handle when showMidpoint is true', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
expect(wrapper.find('[data-testid="handle-midpoint"]').exists()).toBe(true)
})
it('renders gradient background when display is gradient', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'gradient',
gradientStops: [
{ offset: 0, color: [0, 0, 0] as const },
{ offset: 1, color: [255, 255, 255] as const }
]
})
expect(wrapper.find('[data-testid="gradient-bg"]').exists()).toBe(true)
expect(wrapper.find('linearGradient').exists()).toBe(true)
})
it('renders histogram path when display is histogram with data', () => {
const histogram = new Uint32Array(256)
for (let i = 0; i < 256; i++)
histogram[i] = Math.floor(50 + 50 * Math.sin(i / 20))
const wrapper = mountEditor({
modelValue: { min: 0, max: 1 },
display: 'histogram',
histogram
})
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(true)
})
it('renders inputs for min and max', () => {
const wrapper = mountEditor({ modelValue: { min: 0.2, max: 0.8 } })
const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(2)
})
it('renders midpoint input when showMidpoint is true', () => {
const wrapper = mountEditor({
modelValue: { min: 0, max: 1, midpoint: 0.5 },
showMidpoint: true
})
const inputs = wrapper.findAll('input')
expect(inputs).toHaveLength(3)
})
it('normalizes handle positions with custom value range', () => {
const wrapper = mountEditor({
modelValue: { min: 64, max: 192 },
valueMin: 0,
valueMax: 255
})
const minHandle = wrapper.find('[data-testid="handle-min"]')
const maxHandle = wrapper.find('[data-testid="handle-max"]')
// 64/255 ≈ 25.1%, 192/255 ≈ 75.3%
expect(minHandle.attributes('style')).toContain('left: 25.0')
expect(maxHandle.attributes('style')).toContain('left: 75.')
})
})

View File

@@ -1,283 +0,0 @@
<template>
<div>
<div
ref="trackRef"
class="relative select-none"
@pointerdown.stop="handleTrackPointerDown"
@contextmenu.prevent.stop
>
<!-- Track -->
<svg
viewBox="0 0 1 1"
preserveAspectRatio="none"
:class="
cn(
'block w-full rounded-sm bg-node-component-surface',
display === 'histogram' ? 'aspect-[3/2]' : 'h-8'
)
"
>
<defs v-if="display === 'gradient'">
<linearGradient :id="gradientId" x1="0" y1="0" x2="1" y2="0">
<stop
v-for="(stop, i) in computedStops"
:key="i"
:offset="stop.offset"
:stop-color="`rgb(${stop.color[0]},${stop.color[1]},${stop.color[2]})`"
/>
</linearGradient>
</defs>
<rect
v-if="display === 'gradient'"
data-testid="gradient-bg"
x="0"
y="0"
width="1"
height="1"
:fill="`url(#${gradientId})`"
/>
<path
v-if="display === 'histogram' && histogramPath"
data-testid="histogram-path"
:d="histogramPath"
fill="currentColor"
fill-opacity="0.3"
/>
<rect
v-if="display === 'plain'"
data-testid="range-highlight"
:x="minNorm"
y="0"
:width="Math.max(0, maxNorm - minNorm)"
height="1"
fill="white"
fill-opacity="0.15"
/>
<template v-if="display === 'histogram'">
<rect
v-if="minNorm > 0"
data-testid="range-dim-left"
x="0"
y="0"
:width="minNorm"
height="1"
fill="black"
fill-opacity="0.5"
/>
<rect
v-if="maxNorm < 1"
data-testid="range-dim-right"
:x="maxNorm"
y="0"
:width="1 - maxNorm"
height="1"
fill="black"
fill-opacity="0.5"
/>
</template>
</svg>
<!-- Min handle -->
<div
data-testid="handle-min"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${minNorm * 100}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('min', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="#333"
stroke="#aaa"
stroke-width="0.5"
/>
</svg>
</div>
<!-- Midpoint handle -->
<div
v-if="showMidpoint && modelValue.midpoint !== undefined"
data-testid="handle-midpoint"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${midpointPercent}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('midpoint', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="#888"
stroke="#ccc"
stroke-width="0.5"
/>
</svg>
</div>
<!-- Max handle -->
<div
data-testid="handle-max"
class="absolute -translate-x-1/2 cursor-grab"
:style="{ left: `${maxNorm * 100}%`, bottom: '-10px' }"
@pointerdown.stop="startDrag('max', $event)"
>
<svg width="12" height="10" viewBox="0 0 12 10">
<polygon
points="6,0 0,10 12,10"
fill="white"
stroke="#555"
stroke-width="0.5"
/>
</svg>
</div>
</div>
<!-- Value inputs -->
<div class="mt-3 flex items-center justify-between" @pointerdown.stop>
<ScrubableNumberInput
v-model="minValue"
:display-value="formatValue(minValue)"
:min="valueMin"
:max="valueMax"
:step="step"
hide-buttons
class="w-16"
/>
<ScrubableNumberInput
v-if="showMidpoint && modelValue.midpoint !== undefined"
v-model="midpointValue"
:display-value="midpointValue.toFixed(2)"
:min="midpointScale === 'gamma' ? 0.01 : 0"
:max="midpointScale === 'gamma' ? 9.99 : 1"
:step="0.01"
hide-buttons
class="w-16"
/>
<ScrubableNumberInput
v-model="maxValue"
:display-value="formatValue(maxValue)"
:min="valueMin"
:max="valueMax"
:step="step"
hide-buttons
class="w-16"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRef, useId, useTemplateRef } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import { cn } from '@/utils/tailwindUtil'
import { histogramToPath } from '@/components/curve/curveUtils'
import { useRangeEditor } from '@/composables/useRangeEditor'
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import {
clamp,
clamp01,
gammaToPosition,
normalize,
positionToGamma
} from './rangeUtils'
const {
display = 'plain',
gradientStops,
showMidpoint = false,
midpointScale = 'linear',
histogram,
valueMin = 0,
valueMax = 1
} = defineProps<{
display?: 'plain' | 'gradient' | 'histogram'
gradientStops?: ColorStop[]
showMidpoint?: boolean
midpointScale?: 'linear' | 'gamma'
histogram?: Uint32Array | null
valueMin?: number
valueMax?: number
}>()
const modelValue = defineModel<RangeValue>({ required: true })
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
const gradientId = useId()
const { handleTrackPointerDown, startDrag } = useRangeEditor({
trackRef,
modelValue,
valueMin: toRef(() => valueMin),
valueMax: toRef(() => valueMax)
})
const isIntegerRange = computed(() => valueMax - valueMin >= 2)
const step = computed(() => (isIntegerRange.value ? 1 : 0.01))
function formatValue(v: number): string {
return isIntegerRange.value ? Math.round(v).toString() : v.toFixed(2)
}
/** Normalize an actual value to 0-1 for SVG positioning */
const minNorm = computed(() =>
normalize(modelValue.value.min, valueMin, valueMax)
)
const maxNorm = computed(() =>
normalize(modelValue.value.max, valueMin, valueMax)
)
const computedStops = computed(
() =>
gradientStops ?? [
{ offset: 0, color: [0, 0, 0] as const },
{ offset: 1, color: [255, 255, 255] as const }
]
)
const midpointPercent = computed(() => {
const { min, max, midpoint } = modelValue.value
if (midpoint === undefined) return 0
const midAbs = min + midpoint * (max - min)
return normalize(midAbs, valueMin, valueMax) * 100
})
const minValue = computed({
get: () => modelValue.value.min,
set: (min) => {
modelValue.value = {
...modelValue.value,
min: Math.min(clamp(min, valueMin, valueMax), modelValue.value.max)
}
}
})
const maxValue = computed({
get: () => modelValue.value.max,
set: (max) => {
modelValue.value = {
...modelValue.value,
max: Math.max(clamp(max, valueMin, valueMax), modelValue.value.min)
}
}
})
const midpointValue = computed({
get: () => {
const pos = modelValue.value.midpoint ?? 0.5
return midpointScale === 'gamma' ? positionToGamma(pos) : pos
},
set: (val) => {
const position =
midpointScale === 'gamma' ? clamp01(gammaToPosition(val)) : clamp01(val)
modelValue.value = { ...modelValue.value, midpoint: position }
}
})
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)
</script>

View File

@@ -1,30 +0,0 @@
<template>
<RangeEditor
v-model="modelValue"
:display="widget?.options?.display"
:gradient-stops="widget?.options?.gradient_stops"
:show-midpoint="widget?.options?.show_midpoint"
:midpoint-scale="widget?.options?.midpoint_scale"
:histogram="widget?.options?.histogram"
:value-min="widget?.options?.value_min"
:value-max="widget?.options?.value_max"
/>
</template>
<script setup lang="ts">
import type {
IWidgetRangeOptions,
RangeValue
} from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import RangeEditor from './RangeEditor.vue'
defineProps<{
widget?: SimplifiedWidget<RangeValue, IWidgetRangeOptions>
}>()
const modelValue = defineModel<RangeValue>({
default: () => ({ min: 0, max: 1 })
})
</script>

View File

@@ -1,131 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
clamp,
clamp01,
constrainRange,
denormalize,
formatMidpointLabel,
gammaToPosition,
normalize,
positionToGamma
} from './rangeUtils'
describe('clamp', () => {
it('clamps to arbitrary range', () => {
expect(clamp(128, 0, 255)).toBe(128)
expect(clamp(-10, 0, 255)).toBe(0)
expect(clamp(300, 0, 255)).toBe(255)
})
})
describe('normalize', () => {
it('normalizes value to 0-1', () => {
expect(normalize(128, 0, 256)).toBe(0.5)
expect(normalize(0, 0, 255)).toBe(0)
expect(normalize(255, 0, 255)).toBe(1)
})
it('returns 0 when min equals max', () => {
expect(normalize(5, 5, 5)).toBe(0)
})
})
describe('denormalize', () => {
it('converts normalized value back to range', () => {
expect(denormalize(0.5, 0, 256)).toBe(128)
expect(denormalize(0, 0, 255)).toBe(0)
expect(denormalize(1, 0, 255)).toBe(255)
})
it('round-trips with normalize', () => {
expect(denormalize(normalize(100, 0, 255), 0, 255)).toBeCloseTo(100)
})
})
describe('clamp01', () => {
it('returns value within bounds unchanged', () => {
expect(clamp01(0.5)).toBe(0.5)
})
it('clamps values below 0', () => {
expect(clamp01(-0.5)).toBe(0)
})
it('clamps values above 1', () => {
expect(clamp01(1.5)).toBe(1)
})
it('returns 0 for 0', () => {
expect(clamp01(0)).toBe(0)
})
it('returns 1 for 1', () => {
expect(clamp01(1)).toBe(1)
})
})
describe('positionToGamma', () => {
it('converts 0.5 to gamma 1.0', () => {
expect(positionToGamma(0.5)).toBeCloseTo(1.0)
})
it('converts 0.25 to gamma 2.0', () => {
expect(positionToGamma(0.25)).toBeCloseTo(2.0)
})
})
describe('gammaToPosition', () => {
it('converts gamma 1.0 to position 0.5', () => {
expect(gammaToPosition(1.0)).toBeCloseTo(0.5)
})
it('converts gamma 2.0 to position 0.25', () => {
expect(gammaToPosition(2.0)).toBeCloseTo(0.25)
})
it('round-trips with positionToGamma', () => {
for (const pos of [0.1, 0.3, 0.5, 0.7, 0.9]) {
expect(gammaToPosition(positionToGamma(pos))).toBeCloseTo(pos)
}
})
})
describe('formatMidpointLabel', () => {
it('formats linear scale as decimal', () => {
expect(formatMidpointLabel(0.5, 'linear')).toBe('0.50')
})
it('formats gamma scale as gamma value', () => {
expect(formatMidpointLabel(0.5, 'gamma')).toBe('1.00')
})
})
describe('constrainRange', () => {
it('passes through valid range unchanged', () => {
const result = constrainRange({ min: 0.2, max: 0.8 })
expect(result).toEqual({ min: 0.2, max: 0.8, midpoint: undefined })
})
it('clamps values to [0, 1]', () => {
const result = constrainRange({ min: -0.5, max: 1.5 })
expect(result.min).toBe(0)
expect(result.max).toBe(1)
})
it('enforces min <= max', () => {
const result = constrainRange({ min: 0.8, max: 0.3 })
expect(result.min).toBe(0.8)
expect(result.max).toBe(0.8)
})
it('preserves midpoint when present', () => {
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 0.5 })
expect(result.midpoint).toBe(0.5)
})
it('clamps midpoint to [0, 1]', () => {
const result = constrainRange({ min: 0.2, max: 0.8, midpoint: 1.5 })
expect(result.midpoint).toBe(1)
})
})

View File

@@ -1,48 +0,0 @@
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
export function clamp01(value: number): number {
return clamp(value, 0, 1)
}
export function normalize(value: number, min: number, max: number): number {
return max === min ? 0 : (value - min) / (max - min)
}
export function denormalize(
normalized: number,
min: number,
max: number
): number {
return min + normalized * (max - min)
}
export function positionToGamma(position: number): number {
const clamped = Math.max(0.001, Math.min(0.999, position))
return -Math.log2(clamped)
}
export function gammaToPosition(gamma: number): number {
return Math.pow(2, -gamma)
}
export function formatMidpointLabel(
position: number,
scale: 'linear' | 'gamma'
): string {
if (scale === 'gamma') {
return positionToGamma(position).toFixed(2)
}
return position.toFixed(2)
}
export function constrainRange(value: RangeValue): RangeValue {
const min = clamp01(value.min)
const max = clamp01(Math.max(min, value.max))
const midpoint =
value.midpoint !== undefined ? clamp01(value.midpoint) : undefined
return { min, max, midpoint }
}

View File

@@ -40,8 +40,7 @@ const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const { findParentGroup } = useGraphHierarchy()
@@ -110,21 +109,9 @@ const hasContainerInternalError = computed(() => {
})
})
const hasMissingNodeSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingNodeGraphIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value
)
return hasDirectNodeError.value || hasContainerInternalError.value
})
const tabs = computed<RightSidePanelTabList>(() => {

View File

@@ -17,26 +17,24 @@
>
{{ card.nodeTitle }}
</span>
<div class="flex items-center shrink-0">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0 h-8"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="rounded-lg text-sm shrink-0"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-3.5" />
</Button>
</div>
<!-- Multiple Errors within one Card -->

View File

@@ -1,79 +0,0 @@
<template>
<div class="px-4 pb-2">
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
{{
isCloud
? t('rightSidePanel.missingNodePacks.cloudMessage')
: t('rightSidePanel.missingNodePacks.ossMessage')
}}
</p>
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
</div>
<!-- Apply Changes: shown when manager enabled and at least one pack install succeeded -->
<div v-if="shouldShowManagerButtons" class="px-4">
<Button
v-if="hasInstalledPacksPendingRestart"
variant="primary"
:disabled="isRestarting"
class="w-full h-9 justify-center gap-2 text-sm font-semibold mt-2"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="14" />
<i v-else class="icon-[lucide--refresh-cw] size-4 shrink-0" />
<span class="truncate min-w-0">{{
t('rightSidePanel.missingNodePacks.applyChanges')
}}</span>
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useApplyChanges } from '@/workbench/extensions/manager/composables/useApplyChanges'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { isCloud } from '@/platform/distribution/types'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
const props = defineProps<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
openManagerInfo: [packId: string]
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { isRestarting, applyChanges } = useApplyChanges()
const { shouldShowManagerButtons } = useManagerState()
/**
* Show Apply Changes when any pack from the error group is already installed
* on disk but ComfyUI hasn't restarted yet to load it.
* This is server-state based → persists across browser refreshes.
*/
const hasInstalledPacksPendingRestart = computed(() =>
props.missingPackGroups.some(
(g) => g.packId !== null && comfyManagerStore.isPackInstalled(g.packId)
)
)
</script>

View File

@@ -1,250 +0,0 @@
<template>
<div class="flex flex-col w-full mb-2">
<!-- Pack header row: pack name + info + chevron -->
<div class="flex h-8 items-center w-full">
<!-- Warning icon for unknown packs -->
<i
v-if="group.packId === null && !group.isResolving"
class="icon-[lucide--triangle-alert] size-4 text-warning-background shrink-0 mr-1.5"
/>
<p
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"
:class="
group.packId === null && !group.isResolving
? 'text-warning-background'
: 'text-foreground'
"
>
<span v-if="group.isResolving" class="text-muted-foreground italic">
{{ t('g.loading') }}...
</span>
<span v-else>
{{
`${group.packId ?? t('rightSidePanel.missingNodePacks.unknownPack')} (${group.nodeTypes.length})`
}}
</span>
</p>
<Button
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-base-foreground shrink-0"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
<i class="icon-[lucide--info] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse')
: t('rightSidePanel.missingNodePacks.expand')
"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="flex flex-col gap-0.5 pl-2 mb-1 overflow-hidden"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
>
#{{ nodeType.nodeId }}
</span>
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Install button: shown when manager enabled, registry knows the pack or it's already installed -->
<div
v-if="
shouldShowManagerButtons &&
group.packId !== null &&
(nodePack || comfyManagerStore.isPackInstalled(group.packId))
"
class="flex items-start w-full pt-1 pb-1"
>
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@click="handlePackInstallClick"
>
<DotSpinner
v-if="isInstalling"
duration="1s"
:size="12"
class="mr-1.5 shrink-0"
/>
<i
v-else-if="comfyManagerStore.isPackInstalled(group.packId)"
class="icon-[lucide--check] size-4 text-foreground shrink-0 mr-1"
/>
<i
v-else
class="icon-[lucide--download] size-4 text-foreground shrink-0 mr-1"
/>
<span class="text-sm text-foreground truncate min-w-0">
{{
isInstalling
? t('rightSidePanel.missingNodePacks.installing')
: comfyManagerStore.isPackInstalled(group.packId)
? t('rightSidePanel.missingNodePacks.installed')
: t('rightSidePanel.missingNodePacks.installNodePack')
}}
</span>
</Button>
</div>
<!-- Registry still loading: packId known but result not yet available -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons && isLoading"
class="flex items-start w-full pt-1 pb-1"
>
<div
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 bg-secondary-background opacity-60 cursor-not-allowed select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('g.loading') }}
</span>
</div>
</div>
<!-- Search in Manager: fetch done but pack not found in registry -->
<div
v-else-if="group.packId !== null && shouldShowManagerButtons"
class="flex items-start w-full pt-1 pb-1"
>
<Button
variant="secondary"
size="md"
class="flex flex-1 w-full"
@click="
openManager({
initialTab: ManagerTab.All,
initialPackId: group.packId!
})
"
>
<i class="icon-[lucide--search] size-4 text-foreground shrink-0 mr-1" />
<span class="text-sm text-foreground truncate min-w-0">
{{ t('rightSidePanel.missingNodePacks.searchInManager') }}
</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const props = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
openManagerInfo: [packId: string]
}>()
const { t } = useI18n()
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const { shouldShowManagerButtons, openManager } = useManagerState()
const nodePack = computed(() => {
if (!props.group.packId) return null
return missingNodePacks.value.find((p) => p.id === props.group.packId) ?? null
})
const { isInstalling, installAllPacks } = usePackInstall(() =>
nodePack.value ? [nodePack.value] : []
)
function handlePackInstallClick() {
if (!props.group.packId) return
if (!comfyManagerStore.isPackInstalled(props.group.packId)) {
void installAllPacks()
}
}
const expanded = ref(false)
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locateNode', String(nodeType.nodeId))
}
}
</script>

View File

@@ -18,8 +18,7 @@ vi.mock('@/scripts/app', () => ({
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
forEachNode: vi.fn()
}))
vi.mock('@/composables/useCopyToClipboard', () => ({

View File

@@ -27,7 +27,6 @@
:key="group.title"
:collapse="collapseState[group.title] ?? false"
class="border-b border-interface-stroke"
:size="group.type === 'missing_node' ? 'lg' : 'default'"
@update:collapse="collapseState[group.title] = $event"
>
<template #label>
@@ -37,53 +36,20 @@
class="icon-[lucide--octagon-alert] size-4 text-destructive-background-hover shrink-0"
/>
<span class="text-destructive-background-hover truncate">
{{
group.type === 'missing_node'
? `${group.title} (${missingPackGroups.length})`
: group.title
}}
{{ group.title }}
</span>
<span
v-if="group.type === 'execution' && group.cards.length > 1"
v-if="group.cards.length > 1"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
</span>
</span>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
</div>
</template>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:show-node-id-badge="showNodeIdBadge"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Execution Errors -->
<div v-else class="px-4 space-y-3">
<!-- Cards in Group (default slot) -->
<div class="px-4 space-y-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
@@ -142,18 +108,12 @@ import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
const { t } = useI18n()
@@ -162,11 +122,6 @@ const { focusNode, enterSubgraph } = useFocusNode()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const searchQuery = ref('')
@@ -181,9 +136,7 @@ const {
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups
errorNodeCache
} = useErrorGroups(searchQuery, t)
/**
@@ -198,13 +151,11 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
collapseState[group.title] = !hasMatch
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -216,19 +167,6 @@ function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}

View File

@@ -14,11 +14,8 @@ export interface ErrorCardData {
errors: ErrorItem[]
}
export type ErrorGroup =
| {
type: 'execution'
title: string
cards: ErrorCardData[]
priority: number
}
| { type: 'missing_node'; title: string; priority: number }
export interface ErrorGroup {
title: string
cards: ErrorCardData[]
priority: number
}

View File

@@ -1,11 +1,11 @@
import { computed, reactive, ref, watch } from 'vue'
import { computed, reactive } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { isCloud } from '@/platform/distribution/types'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
@@ -20,7 +20,6 @@ import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
@@ -33,17 +32,7 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
'server_error'
])
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
isResolving: boolean
}
interface GroupEntry {
type: 'execution'
priority: number
cards: Map<string, ErrorCardData>
}
@@ -87,7 +76,7 @@ function getOrCreateGroup(
): Map<string, ErrorCardData> {
let entry = groupsMap.get(title)
if (!entry) {
entry = { type: 'execution', priority, cards: new Map() }
entry = { priority, cards: new Map() }
groupsMap.set(title, entry)
}
return entry.cards
@@ -148,7 +137,6 @@ function addCardErrorToGroup(
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
type: 'execution' as const,
title,
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
@@ -165,7 +153,6 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
const searchableList: ErrorSearchItem[] = []
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi]
if (group.type !== 'execution') continue
for (let ci = 0; ci < group.cards.length; ci++) {
const card = group.cards[ci]
searchableList.push({
@@ -173,12 +160,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
})
}
}
@@ -201,16 +184,11 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
)
return groups
.map((group, gi) => {
if (group.type !== 'execution') return group
return {
...group,
cards: group.cards.filter((_: ErrorCardData, ci: number) =>
matchedCardKeys.has(`${gi}:${ci}`)
)
}
})
.filter((group) => group.type !== 'execution' || group.cards.length > 0)
.map((group, gi) => ({
...group,
cards: group.cards.filter((_, ci) => matchedCardKeys.has(`${gi}:${ci}`))
}))
.filter((group) => group.cards.length > 0)
}
export function useErrorGroups(
@@ -219,7 +197,6 @@ export function useErrorGroups(
) {
const executionErrorStore = useExecutionErrorStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
const selectedNodeInfo = computed(() => {
@@ -260,19 +237,6 @@ export function useErrorGroups(
return map
})
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
const nodeId = String(nodeType.nodeId)
const node = getNodeByExecutionId(app.rootGraph, nodeId)
if (node) map.set(nodeId, node)
}
return map
})
function isErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
@@ -379,136 +343,6 @@ export function useErrorGroups(
)
}
// Async pack-ID resolution for missing node types that lack a cnrId
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
)
watch(
pendingTypes,
async (pending, _, onCleanup) => {
const toResolve = pending.filter(
(n) => asyncResolvedIds.value.get(n.type) === undefined
)
if (!toResolve.length) return
const resolvingTypes = toResolve.map((n) => n.type)
let cancelled = false
onCleanup(() => {
cancelled = true
const next = new Map(asyncResolvedIds.value)
for (const type of resolvingTypes) {
if (next.get(type) === RESOLVING) next.delete(type)
}
asyncResolvedIds.value = next
})
const updated = new Map(asyncResolvedIds.value)
for (const type of resolvingTypes) updated.set(type, RESOLVING)
asyncResolvedIds.value = updated
const results = await Promise.allSettled(
toResolve.map(async (n) => ({
type: n.type,
packId: (await inferPackFromNodeName.call(n.type))?.id ?? null
}))
)
if (cancelled) return
const final = new Map(asyncResolvedIds.value)
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
}
}
// Clear any remaining RESOLVING markers for failed lookups
for (const type of resolvingTypes) {
if (final.get(type) === RESOLVING) final.set(type, null)
}
asyncResolvedIds.value = final
},
{ immediate: true }
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
>()
const resolvingKeys = new Set<string | null>()
for (const nodeType of nodeTypes) {
let packId: string | null
if (typeof nodeType === 'string') {
packId = null
} else if (nodeType.cnrId) {
packId = nodeType.cnrId
} else {
const resolved = asyncResolvedIds.value.get(nodeType.type)
if (resolved === undefined || resolved === RESOLVING) {
packId = null
resolvingKeys.add(null)
} else {
packId = resolved
}
}
const existing = map.get(packId)
if (existing) {
existing.nodeTypes.push(nodeType)
} else {
map.set(packId, { nodeTypes: [nodeType], isResolving: false })
}
}
for (const key of resolvingKeys) {
const group = map.get(key)
if (group) group.isResolving = true
}
return Array.from(map.entries())
.sort(([packIdA], [packIdB]) => {
// null (Unknown Pack) always goes last
if (packIdA === null) return 1
if (packIdB === null) return -1
return packIdA.localeCompare(packIdB)
})
.map(([packId, { nodeTypes, isResolving }]) => ({
packId,
nodeTypes: [...nodeTypes].sort((a, b) => {
const typeA = typeof a === 'string' ? a : a.type
const typeB = typeof b === 'string' ? b : b.type
const typeCmp = typeA.localeCompare(typeB)
if (typeCmp !== 0) return typeCmp
const idA = typeof a === 'string' ? '' : String(a.nodeId ?? '')
const idB = typeof b === 'string' ? '' : String(b.nodeId ?? '')
return idA.localeCompare(idB, undefined, { numeric: true })
}),
isResolving
}))
})
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = executionErrorStore.missingNodesError
if (!error) return []
return [
{
type: 'missing_node' as const,
title: error.message,
priority: 0
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -516,7 +350,7 @@ export function useErrorGroups(
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
return toSortedGroups(groupsMap)
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
@@ -526,11 +360,9 @@ export function useErrorGroups(
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
const executionGroups = isSingleNodeSelected.value
return isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
return [...buildMissingNodeGroups(), ...executionGroups]
})
const filteredGroups = computed<ErrorGroup[]>(() => {
@@ -541,15 +373,10 @@ export function useErrorGroups(
const groupedErrorMessages = computed<string[]>(() => {
const messages = new Set<string>()
for (const group of allErrorGroups.value) {
if (group.type === 'execution') {
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
for (const card of group.cards) {
for (const err of card.errors) {
messages.add(err.message)
}
} else {
// Groups without cards (e.g. missing_node) surface their title as the message.
messages.add(group.title)
}
}
return Array.from(messages)
@@ -562,8 +389,6 @@ export function useErrorGroups(
collapseState,
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
groupedErrorMessages,
missingPackGroups
groupedErrorMessages
}
}

View File

@@ -10,14 +10,12 @@ const {
label,
enableEmptyState,
tooltip,
size = 'default',
class: className
} = defineProps<{
disabled?: boolean
label?: string
enableEmptyState?: boolean
tooltip?: string
size?: 'default' | 'lg'
class?: string
}>()
@@ -41,8 +39,7 @@ const tooltipConfig = computed(() => {
type="button"
:class="
cn(
'group bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
size === 'lg' ? 'min-h-16' : 'min-h-12',
'group min-h-12 bg-transparent border-0 outline-0 ring-0 w-full text-left flex items-center justify-between pl-4 pr-3',
!disabled && 'cursor-pointer'
)
"

View File

@@ -131,12 +131,6 @@ const nodeHasError = computed(() => {
return hasDirectError.value || hasContainerInternalError.value
})
const showSeeError = computed(
() =>
nodeHasError.value &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
)
const parentGroup = computed<LGraphGroup | null>(() => {
if (!targetNode.value || !getNodeParentGroup) return null
return getNodeParentGroup(targetNode.value)
@@ -200,7 +194,6 @@ defineExpose({
:enable-empty-state
:disabled="isEmpty"
:tooltip
:size="showSeeError ? 'lg' : 'default'"
>
<template #label>
<div class="flex flex-wrap items-center gap-2 flex-1 min-w-0">
@@ -230,10 +223,13 @@ defineExpose({
</span>
</span>
<Button
v-if="showSeeError"
v-if="
nodeHasError &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
"
variant="secondary"
size="sm"
class="shrink-0 rounded-lg text-sm h-8"
class="shrink-0 rounded-lg text-sm"
@click.stop="navigateToErrorTab"
>
{{ t('rightSidePanel.seeError') }}

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import {
demoteWidget,
@@ -18,10 +17,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@/utils/tailwindUtil'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
@@ -31,6 +30,9 @@ const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const draggableList = ref<DraggableList | undefined>(undefined)
const draggableItems = ref()
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
@@ -193,9 +195,54 @@ function showRecommended() {
}
}
function setDraggableState() {
draggableList.value?.dispose()
if (searchQuery.value || !draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const aw = activeWidgets.value
const [w] = aw.splice(oldPosition, 1)
aw.splice(newPosition, 0, w)
activeWidgets.value = aw
}
}
watch(filteredActive, () => {
setDraggableState()
})
onMounted(() => {
setDraggableState()
if (activeNode.value) pruneDisconnected(activeNode.value)
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
@@ -233,18 +280,19 @@ onMounted(() => {
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
class="bg-comfy-menu-bg"
:node-title="node.title"
:widget-name="widget.name"
:is-physical="node.id === -1"
:is-shown="true"
:is-draggable="!searchQuery"
:is-physical="node.id === -1"
@toggle-visibility="demote([node, widget])"
/>
</DraggableList>
</div>
</div>
<div

View File

@@ -29,7 +29,8 @@ function getIcon() {
cn(
'flex py-1 px-2 break-all rounded items-center gap-1',
'bg-node-component-surface',
props.isDraggable && 'hover:ring-1 ring-accent-background',
props.isDraggable &&
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing hover:ring-1 ring-accent-background',
props.class
)
"

View File

@@ -106,42 +106,4 @@ describe('AssetsSidebarListView', () => {
expect(assetListItem?.props('previewUrl')).toBe('')
expect(assetListItem?.props('isVideoPreview')).toBe(false)
})
it('emits preview-asset when item preview is clicked', async () => {
const imageAsset = {
...buildAsset('image-asset', 'image.png'),
preview_url: '/api/view/image.png',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
assetListItem!.vm.$emit('preview-click')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('emits preview-asset when item is double-clicked', async () => {
const imageAsset = {
...buildAsset('image-asset-dbl', 'image.png'),
preview_url: '/api/view/image.png',
user_metadata: {}
} satisfies AssetItem
const wrapper = mountListView([buildOutputItem(imageAsset)])
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
const assetListItem = listItems.at(-1)
expect(assetListItem).toBeDefined()
await assetListItem!.trigger('dblclick')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
})

View File

@@ -56,8 +56,6 @@
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
@@ -118,7 +116,6 @@ const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'preview-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()

View File

@@ -95,7 +95,6 @@
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
@@ -217,6 +216,10 @@ import {
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
@@ -248,10 +251,6 @@ import {
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t } = useI18n()
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()

View File

@@ -67,7 +67,7 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
@@ -86,17 +86,11 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
const { t, n } = useI18n()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
@@ -157,24 +151,6 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const previewOutput = item.taskRef?.previewOutput
if (previewOutput?.is3D) {
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: item.title,
component: Load3dViewerContent,
props: {
modelUrl: previewOutput.url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
return
}
await openResultGallery(item)
})

View File

@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -83,163 +82,6 @@ describe('Node Reactivity', () => {
})
})
describe('Widget slotMetadata reactivity on link disconnect', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function createWidgetInputGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
// Start with a connected link
input.link = 42
graph.add(node)
return { graph, node }
}
it('sets slotMetadata.linked to true when input has a link', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with displayName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value'
)
// Host the promoted view on a regular node so we can control widgets
// directly (SubgraphNode.widgets is a synthetic getter).
const graph = new LGraph()
const hostNode = new LGraphNode('host')
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
input.link = 42
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Disconnect
hostNode.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: hostNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -69,12 +69,6 @@ export interface SafeWidgetData {
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
}
export interface VueNodeData {
@@ -244,8 +238,7 @@ function safeWidgetMapper(
options: isPromotedPseudoWidget
? { ...options, canvasOnly: true }
: options,
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined
slotMetadata: slotInfo
}
} catch (error) {
return {
@@ -383,7 +376,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
const slotInfo = slotMetadata.get(widget.name)
if (slotInfo) widget.slotMetadata = slotInfo
}
}

View File

@@ -2,11 +2,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set(['PreviewImage', 'SaveImage'])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)

View File

@@ -1,175 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
mockFetchApi: vi.fn(),
mockAddAlert: vi.fn(),
mockUpdateInputs: vi.fn()
}))
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
useNodeDragAndDrop: (
_node: LGraphNode,
opts: { onDrop: typeof capturedDragOnDrop }
) => {
capturedDragOnDrop = opts.onDrop
}
}))
vi.mock('@/composables/node/useNodeFileInput', () => ({
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
}))
vi.mock('@/composables/node/useNodePaste', () => ({
useNodePaste: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: mockAddAlert })
}))
vi.mock('@/scripts/api', () => ({
api: { fetchApi: mockFetchApi }
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({ updateInputs: mockUpdateInputs })
}))
function createMockNode(): LGraphNode {
return {
isUploading: false,
imgs: [new Image()],
graph: { setDirtyCanvas: vi.fn() },
size: [300, 400]
} as unknown as LGraphNode
}
function createFile(name = 'test.png'): File {
return new File(['data'], name, { type: 'image/png' })
}
function successResponse(name: string, subfolder?: string) {
return {
status: 200,
json: () => Promise.resolve({ name, subfolder })
}
}
function failResponse(status = 500) {
return {
status,
statusText: 'Server Error'
}
}
describe('useNodeImageUpload', () => {
let node: LGraphNode
let onUploadComplete: (paths: string[]) => void
let onUploadStart: (files: File[]) => void
let onUploadError: () => void
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
node = createMockNode()
onUploadComplete = vi.fn()
onUploadStart = vi.fn()
onUploadError = vi.fn()
const { useNodeImageUpload } = await import('./useNodeImageUpload')
useNodeImageUpload(node, {
onUploadComplete,
onUploadStart,
onUploadError,
folder: 'input'
})
})
it('sets isUploading true during upload and false after', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
const promise = capturedDragOnDrop([createFile()])
expect(node.isUploading).toBe(true)
await promise
expect(node.isUploading).toBe(false)
})
it('clears node.imgs on upload start', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
const promise = capturedDragOnDrop([createFile()])
expect(node.imgs).toBeUndefined()
await promise
})
it('calls onUploadStart with files', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
const files = [createFile()]
await capturedDragOnDrop(files)
expect(onUploadStart).toHaveBeenCalledWith(files)
})
it('calls onUploadComplete with valid paths on success', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
await capturedDragOnDrop([createFile()])
expect(onUploadComplete).toHaveBeenCalledWith(['test.png'])
})
it('includes subfolder in returned path', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png', 'pasted'))
await capturedDragOnDrop([createFile()])
expect(onUploadComplete).toHaveBeenCalledWith(['pasted/test.png'])
})
it('calls onUploadError when all uploads fail', async () => {
mockFetchApi.mockResolvedValueOnce(failResponse())
await capturedDragOnDrop([createFile()])
expect(onUploadError).toHaveBeenCalled()
expect(onUploadComplete).not.toHaveBeenCalled()
})
it('resets isUploading even when upload fails', async () => {
mockFetchApi.mockRejectedValueOnce(new Error('Network error'))
await capturedDragOnDrop([createFile()])
expect(node.isUploading).toBe(false)
})
it('rejects concurrent uploads with a toast', async () => {
mockFetchApi.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(successResponse('a.png')), 50)
)
)
const first = capturedDragOnDrop([createFile('a.png')])
const second = await capturedDragOnDrop([createFile('b.png')])
expect(second).toEqual([])
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
await first
})
it('calls setDirtyCanvas on start and finish', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
await capturedDragOnDrop([createFile()])
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,7 +1,6 @@
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
@@ -63,8 +62,6 @@ interface ImageUploadOptions {
* @example 'input', 'output', 'temp'
*/
folder?: ResultItemType
onUploadStart?: (files: File[]) => void
onUploadError?: () => void
}
/**
@@ -93,29 +90,10 @@ export const useNodeImageUpload = (
}
const handleUploadBatch = async (files: File[]) => {
if (node.isUploading) {
useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
return []
}
node.isUploading = true
try {
node.imgs = undefined
node.graph?.setDirtyCanvas(true)
options.onUploadStart?.(files)
const paths = await Promise.all(files.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) {
onUploadComplete(validPaths)
} else {
options.onUploadError?.()
}
return validPaths
} finally {
node.isUploading = false
node.graph?.setDirtyCanvas(true)
}
const paths = await Promise.all(files.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) onUploadComplete(validPaths)
return validPaths
}
// Handle drag & drop

View File

@@ -1,780 +0,0 @@
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useElementSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import {
getEffectiveBrushSize,
getEffectiveHardness
} from '@/composables/maskeditor/brushUtils'
import { StrokeProcessor } from '@/composables/maskeditor/StrokeProcessor'
import { hexToRgb } from '@/utils/colorUtil'
import type { Point } from '@/extensions/core/maskeditor/types'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
type PainterTool = 'brush' | 'eraser'
export const PAINTER_TOOLS: Record<string, PainterTool> = {
BRUSH: 'brush',
ERASER: 'eraser'
} as const
interface UsePainterOptions {
canvasEl: Ref<HTMLCanvasElement | null>
modelValue: Ref<string>
}
export function usePainter(nodeId: string, options: UsePainterOptions) {
const { canvasEl, modelValue } = options
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const isDirty = ref(false)
const canvasWidth = ref(512)
const canvasHeight = ref(512)
const cursorX = ref(0)
const cursorY = ref(0)
const cursorVisible = ref(false)
const inputImageUrl = ref<string | null>(null)
const isImageInputConnected = ref(false)
let isDrawing = false
let strokeProcessor: StrokeProcessor | null = null
let lastPoint: Point | null = null
let cachedRect: DOMRect | null = null
let mainCtx: CanvasRenderingContext2D | null = null
let strokeCanvas: HTMLCanvasElement | null = null
let strokeCtx: CanvasRenderingContext2D | null = null
let baseCanvas: HTMLCanvasElement | null = null
let baseCtx: CanvasRenderingContext2D | null = null
let hasBaseSnapshot = false
let hasStrokes = false
let dirtyX0 = 0
let dirtyY0 = 0
let dirtyX1 = 0
let dirtyY1 = 0
let hasDirtyRect = false
let strokeBrush: {
radius: number
effectiveRadius: number
effectiveHardness: number
hardness: number
r: number
g: number
b: number
} | null = null
const litegraphNode = computed(() => {
if (!nodeId || !app.canvas.graph) return null
return app.canvas.graph.getNodeById(nodeId) as LGraphNode | null
})
function getWidgetByName(name: string): IBaseWidget | undefined {
return litegraphNode.value?.widgets?.find(
(w: IBaseWidget) => w.name === name
)
}
const tool = ref<PainterTool>(PAINTER_TOOLS.BRUSH)
const brushSize = ref(20)
const brushColor = ref('#ffffff')
const brushOpacity = ref(1)
const brushHardness = ref(1)
const backgroundColor = ref('#000000')
function restoreSettingsFromProperties() {
const node = litegraphNode.value
if (!node) return
const props = node.properties
if (props.painterTool != null) tool.value = props.painterTool as PainterTool
if (props.painterBrushSize != null)
brushSize.value = props.painterBrushSize as number
if (props.painterBrushColor != null)
brushColor.value = props.painterBrushColor as string
if (props.painterBrushOpacity != null)
brushOpacity.value = props.painterBrushOpacity as number
if (props.painterBrushHardness != null)
brushHardness.value = props.painterBrushHardness as number
const bgColorWidget = getWidgetByName('bg_color')
if (bgColorWidget) backgroundColor.value = bgColorWidget.value as string
}
function saveSettingsToProperties() {
const node = litegraphNode.value
if (!node) return
node.properties.painterTool = tool.value
node.properties.painterBrushSize = brushSize.value
node.properties.painterBrushColor = brushColor.value
node.properties.painterBrushOpacity = brushOpacity.value
node.properties.painterBrushHardness = brushHardness.value
}
function syncCanvasSizeToWidgets() {
const widthWidget = getWidgetByName('width')
const heightWidget = getWidgetByName('height')
if (widthWidget && widthWidget.value !== canvasWidth.value) {
widthWidget.value = canvasWidth.value
widthWidget.callback?.(canvasWidth.value)
}
if (heightWidget && heightWidget.value !== canvasHeight.value) {
heightWidget.value = canvasHeight.value
heightWidget.callback?.(canvasHeight.value)
}
}
function syncBackgroundColorToWidget() {
const bgColorWidget = getWidgetByName('bg_color')
if (bgColorWidget && bgColorWidget.value !== backgroundColor.value) {
bgColorWidget.value = backgroundColor.value
bgColorWidget.callback?.(backgroundColor.value)
}
}
function updateInputImageUrl() {
const node = litegraphNode.value
if (!node) {
inputImageUrl.value = null
isImageInputConnected.value = false
return
}
isImageInputConnected.value = node.isInputConnected(0)
const inputNode = node.getInputNode(0)
if (!inputNode) {
inputImageUrl.value = null
return
}
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
inputImageUrl.value = urls?.length ? urls[0] : null
}
function syncCanvasSizeFromWidgets() {
const w = getWidgetByName('width')
const h = getWidgetByName('height')
canvasWidth.value = (w?.value as number) ?? 512
canvasHeight.value = (h?.value as number) ?? 512
}
function activeHardness(): number {
return tool.value === PAINTER_TOOLS.ERASER ? 1 : brushHardness.value
}
const { width: canvasDisplayWidth } = useElementSize(canvasEl)
const displayBrushSize = computed(() => {
if (!canvasDisplayWidth.value || !canvasWidth.value) return brushSize.value
const radius = brushSize.value / 2
const effectiveRadius = getEffectiveBrushSize(radius, activeHardness())
const effectiveDiameter = effectiveRadius * 2
return effectiveDiameter * (canvasDisplayWidth.value / canvasWidth.value)
})
function getCtx() {
if (!mainCtx && canvasEl.value) {
mainCtx = canvasEl.value.getContext('2d') ?? null
}
return mainCtx
}
function cacheCanvasRect() {
const el = canvasEl.value
if (el) cachedRect = el.getBoundingClientRect()
}
function getCanvasPoint(e: PointerEvent): Point | null {
const el = canvasEl.value
if (!el) return null
const rect = cachedRect ?? el.getBoundingClientRect()
return {
x: ((e.clientX - rect.left) / rect.width) * el.width,
y: ((e.clientY - rect.top) / rect.height) * el.height
}
}
function expandDirtyRect(cx: number, cy: number, r: number) {
const x0 = cx - r
const y0 = cy - r
const x1 = cx + r
const y1 = cy + r
if (!hasDirtyRect) {
dirtyX0 = x0
dirtyY0 = y0
dirtyX1 = x1
dirtyY1 = y1
hasDirtyRect = true
} else {
if (x0 < dirtyX0) dirtyX0 = x0
if (y0 < dirtyY0) dirtyY0 = y0
if (x1 > dirtyX1) dirtyX1 = x1
if (y1 > dirtyY1) dirtyY1 = y1
}
}
function snapshotBrush() {
const radius = brushSize.value / 2
const hardness = activeHardness()
const effectiveRadius = getEffectiveBrushSize(radius, hardness)
strokeBrush = {
radius,
effectiveRadius,
effectiveHardness: getEffectiveHardness(
radius,
hardness,
effectiveRadius
),
hardness,
...hexToRgb(brushColor.value)
}
}
function drawCircle(ctx: CanvasRenderingContext2D, point: Point) {
const b = strokeBrush!
expandDirtyRect(point.x, point.y, b.effectiveRadius)
ctx.beginPath()
ctx.arc(point.x, point.y, b.effectiveRadius, 0, Math.PI * 2)
if (b.hardness < 1) {
const gradient = ctx.createRadialGradient(
point.x,
point.y,
0,
point.x,
point.y,
b.effectiveRadius
)
gradient.addColorStop(0, `rgba(${b.r}, ${b.g}, ${b.b}, 1)`)
gradient.addColorStop(
b.effectiveHardness,
`rgba(${b.r}, ${b.g}, ${b.b}, 1)`
)
gradient.addColorStop(1, `rgba(${b.r}, ${b.g}, ${b.b}, 0)`)
ctx.fillStyle = gradient
}
ctx.fill()
}
function drawSegment(ctx: CanvasRenderingContext2D, from: Point, to: Point) {
const b = strokeBrush!
if (b.hardness < 1) {
const dx = to.x - from.x
const dy = to.y - from.y
const dist = Math.hypot(dx, dy)
const step = Math.max(1, b.effectiveRadius / 2)
if (dist > 0) {
const steps = Math.ceil(dist / step)
const dabPoint: Point = { x: 0, y: 0 }
for (let i = 1; i <= steps; i++) {
const t = i / steps
dabPoint.x = from.x + dx * t
dabPoint.y = from.y + dy * t
drawCircle(ctx, dabPoint)
}
}
} else {
expandDirtyRect(from.x, from.y, b.effectiveRadius)
ctx.beginPath()
ctx.moveTo(from.x, from.y)
ctx.lineTo(to.x, to.y)
ctx.stroke()
drawCircle(ctx, to)
}
}
function applyBrushStyle(ctx: CanvasRenderingContext2D) {
const b = strokeBrush!
const color = `rgb(${b.r}, ${b.g}, ${b.b})`
ctx.globalCompositeOperation = 'source-over'
ctx.globalAlpha = 1
ctx.fillStyle = color
ctx.strokeStyle = color
ctx.lineWidth = b.effectiveRadius * 2
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
}
function ensureStrokeCanvas() {
const el = canvasEl.value
if (!el) return null
if (
!strokeCanvas ||
strokeCanvas.width !== el.width ||
strokeCanvas.height !== el.height
) {
strokeCanvas = document.createElement('canvas')
strokeCanvas.width = el.width
strokeCanvas.height = el.height
strokeCtx = strokeCanvas.getContext('2d')
}
strokeCtx?.clearRect(0, 0, strokeCanvas.width, strokeCanvas.height)
return strokeCtx
}
function ensureBaseCanvas() {
const el = canvasEl.value
if (!el) return null
if (
!baseCanvas ||
baseCanvas.width !== el.width ||
baseCanvas.height !== el.height
) {
baseCanvas = document.createElement('canvas')
baseCanvas.width = el.width
baseCanvas.height = el.height
baseCtx = baseCanvas.getContext('2d')
}
return baseCtx
}
function compositeStrokeToMain(isPreview: boolean = false) {
const el = canvasEl.value
const ctx = getCtx()
if (!el || !ctx || !strokeCanvas) return
const useDirty = hasDirtyRect
const sx = Math.max(0, Math.floor(dirtyX0))
const sy = Math.max(0, Math.floor(dirtyY0))
const sr = Math.min(el.width, Math.ceil(dirtyX1))
const sb = Math.min(el.height, Math.ceil(dirtyY1))
const sw = sr - sx
const sh = sb - sy
hasDirtyRect = false
if (hasBaseSnapshot && baseCanvas) {
if (useDirty && sw > 0 && sh > 0) {
ctx.clearRect(sx, sy, sw, sh)
ctx.drawImage(baseCanvas, sx, sy, sw, sh, sx, sy, sw, sh)
} else {
ctx.clearRect(0, 0, el.width, el.height)
ctx.drawImage(baseCanvas, 0, 0)
}
}
ctx.save()
const isEraser = tool.value === PAINTER_TOOLS.ERASER
ctx.globalAlpha = isEraser ? 1 : brushOpacity.value
ctx.globalCompositeOperation = isEraser ? 'destination-out' : 'source-over'
if (useDirty && sw > 0 && sh > 0) {
ctx.drawImage(strokeCanvas, sx, sy, sw, sh, sx, sy, sw, sh)
} else {
ctx.drawImage(strokeCanvas, 0, 0)
}
ctx.restore()
if (!isPreview) {
hasBaseSnapshot = false
}
}
function startStroke(e: PointerEvent) {
const point = getCanvasPoint(e)
if (!point) return
const el = canvasEl.value
if (!el) return
const bCtx = ensureBaseCanvas()
if (bCtx) {
bCtx.clearRect(0, 0, el.width, el.height)
bCtx.drawImage(el, 0, 0)
hasBaseSnapshot = true
}
isDrawing = true
isDirty.value = true
hasStrokes = true
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
lastPoint = point
const ctx = ensureStrokeCanvas()
if (!ctx) return
ctx.save()
applyBrushStyle(ctx)
drawCircle(ctx, point)
ctx.restore()
compositeStrokeToMain(true)
}
function continueStroke(e: PointerEvent) {
if (!isDrawing || !strokeProcessor || !strokeCtx) return
const point = getCanvasPoint(e)
if (!point) return
const points = strokeProcessor.addPoint(point)
if (points.length === 0 && lastPoint) {
points.push(point)
}
if (points.length === 0) return
strokeCtx.save()
applyBrushStyle(strokeCtx)
let prev = lastPoint ?? points[0]
for (const p of points) {
drawSegment(strokeCtx, prev, p)
prev = p
}
lastPoint = prev
strokeCtx.restore()
compositeStrokeToMain(true)
}
function endStroke() {
if (!isDrawing || !strokeProcessor) return
const points = strokeProcessor.endStroke()
if (strokeCtx && points.length > 0) {
strokeCtx.save()
applyBrushStyle(strokeCtx)
let prev = lastPoint ?? points[0]
for (const p of points) {
drawSegment(strokeCtx, prev, p)
prev = p
}
strokeCtx.restore()
}
compositeStrokeToMain()
isDrawing = false
strokeProcessor = null
strokeBrush = null
lastPoint = null
}
function resizeCanvas() {
const el = canvasEl.value
if (!el) return
let tmp: HTMLCanvasElement | null = null
if (el.width > 0 && el.height > 0) {
tmp = document.createElement('canvas')
tmp.width = el.width
tmp.height = el.height
tmp.getContext('2d')!.drawImage(el, 0, 0)
}
el.width = canvasWidth.value
el.height = canvasHeight.value
mainCtx = null
if (tmp) {
getCtx()?.drawImage(tmp, 0, 0)
}
strokeCanvas = null
strokeCtx = null
baseCanvas = null
baseCtx = null
hasBaseSnapshot = false
}
function handleClear() {
const el = canvasEl.value
const ctx = getCtx()
if (!el || !ctx) return
ctx.clearRect(0, 0, el.width, el.height)
isDirty.value = true
hasStrokes = false
}
function updateCursorPos(e: PointerEvent) {
cursorX.value = e.offsetX
cursorY.value = e.offsetY
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
cacheCanvasRect()
updateCursorPos(e)
startStroke(e)
}
let pendingMoveEvent: PointerEvent | null = null
let rafId: number | null = null
function flushPendingStroke() {
if (pendingMoveEvent) {
continueStroke(pendingMoveEvent)
pendingMoveEvent = null
}
rafId = null
}
function handlePointerMove(e: PointerEvent) {
updateCursorPos(e)
if (!isDrawing) return
pendingMoveEvent = e
if (!rafId) {
rafId = requestAnimationFrame(flushPendingStroke)
}
}
function handlePointerUp(e: PointerEvent) {
if (e.button !== 0) return
if (rafId) {
cancelAnimationFrame(rafId)
flushPendingStroke()
}
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
endStroke()
}
function handlePointerLeave() {
cursorVisible.value = false
if (rafId) {
cancelAnimationFrame(rafId)
flushPendingStroke()
}
endStroke()
}
function handlePointerEnter() {
cursorVisible.value = true
}
function handleInputImageLoad(e: Event) {
const img = e.target as HTMLImageElement
const widthWidget = getWidgetByName('width')
const heightWidget = getWidgetByName('height')
if (widthWidget) {
widthWidget.value = img.naturalWidth
widthWidget.callback?.(img.naturalWidth)
}
if (heightWidget) {
heightWidget.value = img.naturalHeight
heightWidget.callback?.(img.naturalHeight)
}
canvasWidth.value = img.naturalWidth
canvasHeight.value = img.naturalHeight
}
function parseMaskFilename(value: string): {
filename: string
subfolder: string
type: string
} | null {
const trimmed = value?.trim()
if (!trimmed) return null
const typeMatch = trimmed.match(/^(.+?) \[([^\]]+)\]$/)
const pathPart = typeMatch ? typeMatch[1] : trimmed
const type = typeMatch ? typeMatch[2] : 'input'
const lastSlash = pathPart.lastIndexOf('/')
const subfolder = lastSlash !== -1 ? pathPart.substring(0, lastSlash) : ''
const filename =
lastSlash !== -1 ? pathPart.substring(lastSlash + 1) : pathPart
return { filename, subfolder, type }
}
function isCanvasEmpty(): boolean {
return !hasStrokes
}
async function serializeValue(): Promise<string> {
const el = canvasEl.value
if (!el) return ''
if (isCanvasEmpty()) return ''
if (!isDirty.value) return modelValue.value
const blob = await new Promise<Blob | null>((resolve) =>
el.toBlob(resolve, 'image/png')
)
if (!blob) return modelValue.value
const name = `painter-${nodeId}-${Date.now()}.png`
const body = new FormData()
body.append('image', blob, name)
if (!isCloud) body.append('subfolder', 'painter')
body.append('type', isCloud ? 'input' : 'temp')
let resp: Response
try {
resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
} catch (e) {
const err = t('painter.uploadError', {
status: 0,
statusText: e instanceof Error ? e.message : String(e)
})
toastStore.addAlert(err)
throw new Error(err)
}
if (resp.status !== 200) {
const err = t('painter.uploadError', {
status: resp.status,
statusText: resp.statusText
})
toastStore.addAlert(err)
throw new Error(err)
}
let data: { name: string }
try {
data = await resp.json()
} catch (e) {
const err = t('painter.uploadError', {
status: resp.status,
statusText: e instanceof Error ? e.message : String(e)
})
toastStore.addAlert(err)
throw new Error(err)
}
const result = isCloud
? `${data.name} [input]`
: `painter/${data.name} [temp]`
modelValue.value = result
isDirty.value = false
return result
}
function registerWidgetSerialization() {
const node = litegraphNode.value
if (!node?.widgets) return
const targetWidget = node.widgets.find(
(w: IBaseWidget) => w.name === 'mask'
)
if (targetWidget) {
targetWidget.serializeValue = serializeValue
}
}
function restoreCanvas() {
const parsed = parseMaskFilename(modelValue.value)
if (!parsed) return
const params = new URLSearchParams()
params.set('filename', parsed.filename)
if (parsed.subfolder) params.set('subfolder', parsed.subfolder)
params.set('type', parsed.type)
const url = api.apiURL('/view?' + params.toString())
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const el = canvasEl.value
if (!el) return
canvasWidth.value = img.naturalWidth
canvasHeight.value = img.naturalHeight
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
isDirty.value = false
hasStrokes = true
}
img.onerror = () => {
modelValue.value = ''
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateInputImageUrl, { deep: true })
watch(() => nodeOutputStore.nodePreviewImages, updateInputImageUrl, {
deep: true
})
watch([canvasWidth, canvasHeight], resizeCanvas)
watch(
[tool, brushSize, brushColor, brushOpacity, brushHardness],
saveSettingsToProperties
)
watch([canvasWidth, canvasHeight], syncCanvasSizeToWidgets)
watch(backgroundColor, syncBackgroundColorToWidget)
function initialize() {
syncCanvasSizeFromWidgets()
resizeCanvas()
registerWidgetSerialization()
restoreSettingsFromProperties()
updateInputImageUrl()
restoreCanvas()
}
onMounted(initialize)
onUnmounted(() => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
})
return {
tool,
brushSize,
brushColor,
brushOpacity,
brushHardness,
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
isImageInputConnected,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerEnter,
handlePointerLeave,
handleInputImageLoad,
handleClear
}
}

View File

@@ -11,7 +11,7 @@ import type { TaskItemImpl } from '@/stores/queueStore'
type TestTask = {
jobId: string
job: { priority: number }
queueIndex: number
mockState: JobState
executionTime?: number
executionEndTimestamp?: number
@@ -174,7 +174,7 @@ const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
jobId: overrides.jobId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
job: overrides.job ?? { priority: 0 },
queueIndex: overrides.queueIndex ?? 0,
mockState: overrides.mockState ?? 'pending',
executionTime: overrides.executionTime,
executionEndTimestamp: overrides.executionEndTimestamp,
@@ -258,7 +258,7 @@ describe('useJobList', () => {
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '1', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: '1', queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -287,7 +287,7 @@ describe('useJobList', () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: taskId, queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
@@ -300,7 +300,7 @@ describe('useJobList', () => {
vi.mocked(buildJobDisplay).mockClear()
queueStoreMock.pendingTasks = [
createTask({ jobId: taskId, job: { priority: 2 }, mockState: 'pending' })
createTask({ jobId: taskId, queueIndex: 2, mockState: 'pending' })
]
await flush()
jobItems.value
@@ -314,7 +314,7 @@ describe('useJobList', () => {
it('cleans up timeouts on unmount', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ jobId: '3', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: '3', queueIndex: 1, mockState: 'pending' })
]
initComposable()
@@ -331,7 +331,7 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'p',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending',
createTime: 3000
})
@@ -339,7 +339,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'r',
job: { priority: 5 },
queueIndex: 5,
mockState: 'running',
createTime: 2000
})
@@ -347,7 +347,7 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'h',
job: { priority: 3 },
queueIndex: 3,
mockState: 'completed',
createTime: 1000,
executionEndTimestamp: 5000
@@ -366,9 +366,9 @@ describe('useJobList', () => {
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
createTask({ jobId: 'p', job: { priority: 1 }, mockState: 'pending' })
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ jobId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ jobId: 'p', queueIndex: 1, mockState: 'pending' })
]
const instance = initComposable()
@@ -384,7 +384,7 @@ describe('useJobList', () => {
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' })
createTask({ jobId: 'c', queueIndex: 3, mockState: 'completed' })
]
await flush()
@@ -396,13 +396,13 @@ describe('useJobList', () => {
queueStoreMock.pendingTasks = [
createTask({
jobId: 'wf-1',
job: { priority: 2 },
queueIndex: 2,
mockState: 'pending',
workflowId: 'workflow-1'
}),
createTask({
jobId: 'wf-2',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending',
workflowId: 'workflow-2'
})
@@ -426,14 +426,14 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'alpha',
job: { priority: 2 },
queueIndex: 2,
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
jobId: 'beta',
job: { priority: 1 },
queueIndex: 1,
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
@@ -471,13 +471,13 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'active',
job: { priority: 3 },
queueIndex: 3,
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
jobId: 'other',
job: { priority: 2 },
queueIndex: 2,
mockState: 'running',
executionTime: 3_600_000
})
@@ -507,7 +507,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'live-preview',
job: { priority: 1 },
queueIndex: 1,
mockState: 'running'
})
]
@@ -526,7 +526,7 @@ describe('useJobList', () => {
queueStoreMock.runningTasks = [
createTask({
jobId: 'disabled-preview',
job: { priority: 1 },
queueIndex: 1,
mockState: 'running'
})
]
@@ -567,28 +567,28 @@ describe('useJobList', () => {
queueStoreMock.historyTasks = [
createTask({
jobId: 'today-small',
job: { priority: 4 },
queueIndex: 4,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
jobId: 'today-large',
job: { priority: 3 },
queueIndex: 3,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
jobId: 'yesterday',
job: { priority: 2 },
queueIndex: 2,
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
jobId: 'undated',
job: { priority: 1 },
queueIndex: 1,
mockState: 'pending'
})
]

View File

@@ -4,32 +4,64 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
export function useCopyToClipboard() {
const { copy, copied } = useClipboard({ legacy: true })
const { copy, copied } = useClipboard()
const toast = useToast()
const showSuccessToast = () => {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
}
const showErrorToast = () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
}
async function copyToClipboard(text: string) {
function fallbackCopy(text: string) {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
textarea.setAttribute('aria-hidden', 'true')
textarea.setAttribute('tabindex', '-1')
textarea.style.width = '1px'
textarea.style.height = '1px'
document.body.appendChild(textarea)
textarea.select()
try {
// using legacy document.execCommand for fallback for old and linux browsers
const successful = document.execCommand('copy')
if (successful) {
showSuccessToast()
} else {
showErrorToast()
}
} catch (err) {
showErrorToast()
} finally {
textarea.remove()
}
}
const copyToClipboard = async (text: string) => {
try {
await copy(text)
if (copied.value) {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
showSuccessToast()
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
// If VueUse copy failed, try fallback
fallbackCopy(text)
}
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
} catch (err) {
// VueUse copy failed, try fallback
fallbackCopy(text)
}
}

View File

@@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
import type { CurvePoint } from '@/components/curve/types'
import type { CurvePoint } from '@/lib/litegraph/src/types/widgets'
interface UseCurveEditorOptions {
svgRef: Ref<SVGSVGElement | null>
@@ -21,12 +21,11 @@ export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
const xMin = points[0][0]
const xMax = points[points.length - 1][0]
const segments = 128
const range = xMax - xMin
const parts: string[] = []
for (let i = 0; i <= segments; i++) {
const x = xMin + range * (i / segments)
const x = xMin + (xMax - xMin) * (i / segments)
const y = 1 - interpolate(x)
parts.push(`${i === 0 ? 'M' : 'L'}${x},${y}`)
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(4)},${y.toFixed(4)}`)
}
return parts.join('')
})

View File

@@ -1,114 +0,0 @@
import { onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { clamp, denormalize, normalize } from '@/components/range/rangeUtils'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
type HandleType = 'min' | 'max' | 'midpoint'
interface UseRangeEditorOptions {
trackRef: Ref<HTMLElement | null>
modelValue: Ref<RangeValue>
valueMin: Ref<number>
valueMax: Ref<number>
}
export function useRangeEditor({
trackRef,
modelValue,
valueMin,
valueMax
}: UseRangeEditorOptions) {
const activeHandle = ref<HandleType | null>(null)
let cleanupDrag: (() => void) | null = null
/** Convert pointer event to actual value in [valueMin, valueMax] */
function pointerToValue(e: PointerEvent): number {
const el = trackRef.value
if (!el) return valueMin.value
const rect = el.getBoundingClientRect()
const normalized = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width)
)
return denormalize(normalized, valueMin.value, valueMax.value)
}
function nearestHandle(value: number): HandleType {
const { min, max, midpoint } = modelValue.value
const dMin = Math.abs(value - min)
const dMax = Math.abs(value - max)
let best: HandleType = dMin <= dMax ? 'min' : 'max'
const bestDist = Math.min(dMin, dMax)
if (midpoint !== undefined) {
const midAbs = min + midpoint * (max - min)
if (Math.abs(value - midAbs) < bestDist) {
best = 'midpoint'
}
}
return best
}
function updateValue(handle: HandleType, value: number) {
const current = modelValue.value
const clamped = clamp(value, valueMin.value, valueMax.value)
if (handle === 'min') {
modelValue.value = { ...current, min: Math.min(clamped, current.max) }
} else if (handle === 'max') {
modelValue.value = { ...current, max: Math.max(clamped, current.min) }
} else {
const range = current.max - current.min
const midNorm =
range > 0 ? normalize(clamped, current.min, current.max) : 0
const midpoint = Math.max(0, Math.min(1, midNorm))
modelValue.value = { ...current, midpoint }
}
}
function handleTrackPointerDown(e: PointerEvent) {
if (e.button !== 0) return
startDrag(nearestHandle(pointerToValue(e)), e)
}
function startDrag(handle: HandleType, e: PointerEvent) {
if (e.button !== 0) return
cleanupDrag?.()
activeHandle.value = handle
const el = trackRef.value
if (!el) return
el.setPointerCapture(e.pointerId)
const onMove = (ev: PointerEvent) => {
if (!activeHandle.value) return
updateValue(activeHandle.value, pointerToValue(ev))
}
const endDrag = () => {
if (!activeHandle.value) return
activeHandle.value = null
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', endDrag)
el.removeEventListener('lostpointercapture', endDrag)
cleanupDrag = null
}
cleanupDrag = endDrag
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', endDrag)
el.addEventListener('lostpointercapture', endDrag)
}
onBeforeUnmount(() => {
cleanupDrag?.()
})
return {
activeHandle,
handleTrackPointerDown,
startDrag
}
}

View File

@@ -25,10 +25,7 @@ export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode',
// Shader Nodes
'GLSLShader'
'KlingOmniProEditVideoNode'
])
/**

View File

@@ -201,8 +201,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
suppressPromotedOutline: true
})
projected.y = originalY

View File

@@ -184,17 +184,6 @@ describe('getPromotableWidgets', () => {
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
@@ -243,25 +232,4 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
})

View File

@@ -227,29 +227,6 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
if (hasPreviewWidget()) continue
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
}
continue
}
// Also schedule a deferred check: core $$ widgets are created lazily by
// updatePreviews when node outputs are first loaded.
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))

View File

@@ -19,8 +19,6 @@ if (!isCloud) {
await import('./nodeTemplates')
}
import './noteNode'
import './painter'
import './rangeHistogram'
import './previewAny'
import './rerouteNode'
import './saveImageExtraOutput'

View File

@@ -1,22 +0,0 @@
import { useExtensionService } from '@/services/extensionService'
const HIDDEN_WIDGETS = new Set(['width', 'height', 'bg_color'])
useExtensionService().registerExtension({
name: 'Comfy.Painter',
nodeCreated(node) {
if (node.constructor.comfyClass !== 'Painter') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 450), Math.max(oldHeight, 550)])
node.hideOutputImages = true
for (const widget of node.widgets ?? []) {
if (HIDDEN_WIDGETS.has(widget.name)) {
widget.hidden = true
}
}
}
})

View File

@@ -1,37 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import { useExtensionService } from '@/services/extensionService'
const HISTOGRAM_KEY_PREFIX = 'range_histogram_'
useExtensionService().registerExtension({
name: 'Comfy.RangeHistogram',
async nodeCreated(node: LGraphNode) {
const hasRangeWidget = node.widgets?.some((w) => w.type === 'range')
if (!hasRangeWidget) return
const onExecuted = node.onExecuted
node.onExecuted = function (output: Record<string, unknown>) {
onExecuted?.call(this, output)
for (const widget of node.widgets ?? []) {
if (widget.type !== 'range') continue
const data = output[HISTOGRAM_KEY_PREFIX + widget.name]
if (!Array.isArray(data)) continue
if (widget.options) {
;(widget.options as Record<string, unknown>).histogram =
new Uint32Array(data as number[])
// Force reactive update: widget.options is not reactive, but
// widget.value is (via BaseWidget._state). Re-assigning value
// triggers processedWidgets recomputation in NodeWidgets.vue,
// which then reads the updated options from the store proxy.
widget.value = { ...(widget.value as RangeValue) }
}
}
}
}
})

View File

@@ -43,7 +43,7 @@ async function uploadFile(
file: File,
updateNode: boolean,
pasted: boolean = false
): Promise<boolean> {
) {
try {
// Wrap file in formdata so it includes filename
const body = new FormData()
@@ -76,15 +76,12 @@ async function uploadFile(
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
}
return true
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
return false
}
} catch (error) {
// @ts-expect-error fixme ts strict error
useToastStore().addAlert(error)
return false
}
}
@@ -235,17 +232,7 @@ app.registerExtension({
const handleUpload = async (files: File[]) => {
if (files?.length) {
const previousValue = audioWidget.value
audioWidget.value = files[0].name
const success = await uploadFile(
audioWidget,
audioUIWidget,
files[0],
true
)
if (!success) {
audioWidget.value = previousValue
}
uploadFile(audioWidget, audioUIWidget, files[0], true)
}
return files
}

View File

@@ -1,5 +1,4 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurvePoint } from '@/components/curve/types'
import type {
CanvasColour,
@@ -138,8 +137,6 @@ export type IWidget =
| IImageCropWidget
| IBoundingBoxWidget
| ICurveWidget
| IPainterWidget
| IRangeWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -332,41 +329,13 @@ export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
value: Bounds
}
export type CurvePoint = [x: number, y: number]
export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
type: 'curve'
value: CurvePoint[]
}
export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
type: 'painter'
value: string
}
export interface RangeValue {
min: number
max: number
midpoint?: number
}
export interface IWidgetRangeOptions extends IWidgetOptions {
display?: 'plain' | 'gradient' | 'histogram'
gradient_stops?: ColorStop[]
show_midpoint?: boolean
midpoint_scale?: 'linear' | 'gamma'
value_min?: number
value_max?: number
histogram?: Uint32Array | null
}
export interface IRangeWidget extends IBaseWidget<
RangeValue,
'range',
IWidgetRangeOptions
> {
type: 'range'
value: RangeValue
}
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]
@@ -398,6 +367,7 @@ export interface IBaseWidget<
/** Widget type (see {@link TWidgetType}) */
type: TType
value?: TValue
vueTrack?: () => void
/**
* Whether the widget value is persisted in the workflow JSON

View File

@@ -162,38 +162,6 @@ describe('BaseWidget store integration', () => {
})
})
describe('DOM widget value registration', () => {
it('registers value from getter when value property is overridden', () => {
const defaultValue = 'You are an expert image-generation engine.'
const widget = createTestWidget(node, {
name: 'system_prompt',
value: undefined as unknown as number
})
// Simulate what addDOMWidget does: override value with getter/setter
// that falls back to a default (like inputEl.value for textarea widgets)
Object.defineProperty(widget, 'value', {
get() {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return defaultValue
const state = store.getWidget(graphId, node.id, 'system_prompt')
return (state?.value as string) ?? defaultValue
},
set(v: string) {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return
const state = store.getWidget(graphId, node.id, 'system_prompt')
if (state) state.value = v
}
})
widget.setNodeId(node.id)
const state = store.getWidget(graph.id, node.id, 'system_prompt')
expect(state?.value).toBe(defaultValue)
})
})
describe('fallback behavior', () => {
it('uses internal value before registration', () => {
const widget = createTestWidget(node, {

View File

@@ -27,8 +27,6 @@ export interface DrawWidgetOptions {
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
previewImages?: HTMLImageElement[]
}
interface DrawTruncatingTextOptions extends DrawWidgetOptions {
@@ -142,10 +140,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state = useWidgetValueStore().registerWidget(graphId, {
...this._state,
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
value: this.value,
nodeId
})
}

View File

@@ -1,22 +0,0 @@
import type { IPainterWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for the Painter node canvas drawing tool.
* This is a widget that only has a Vue widgets implementation.
*/
export class PainterWidget
extends BaseWidget<IPainterWidget>
implements IPainterWidget
{
override type = 'painter' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Painter')
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -1,16 +0,0 @@
import type { IRangeWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class RangeWidget
extends BaseWidget<IRangeWidget>
implements IRangeWidget
{
override type = 'range' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Range')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -21,8 +21,6 @@ import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { GradientSliderWidget } from './GradientSliderWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { ImageCropWidget } from './ImageCropWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
@@ -60,8 +58,6 @@ export type WidgetTypeMap = {
imagecrop: ImageCropWidget
boundingbox: BoundingBoxWidget
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
[key: string]: BaseWidget
}
@@ -140,10 +136,6 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(BoundingBoxWidget, narrowedWidget, node)
case 'curve':
return toClass(CurveWidget, narrowedWidget, node)
case 'painter':
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "و",
"backToLogin": "العودة إلى تسجيل الدخول",
"backToSocialLogin": "سجّل باستخدام Google أو Github بدلاً من ذلك",
"confirmPasswordLabel": "تأكيد كلمة المرور",
"confirmPasswordPlaceholder": "أدخل نفس كلمة المرور مرة أخرى",
"didntReceiveEmail": "لم تستلم البريد الإلكتروني؟ اتصل بنا على",
@@ -234,9 +233,6 @@
"failed": "فشل تسجيل الدخول",
"forgotPassword": "هل نسيت كلمة المرور؟",
"forgotPasswordError": "فشل في إرسال بريد إعادة تعيين كلمة المرور",
"freeTierBadge": "مؤهل للخطة المجانية",
"freeTierDescription": "سجّل باستخدام Google للحصول على {credits} رصيد مجاني كل شهر. لا حاجة لبطاقة.",
"freeTierDescriptionGeneric": "سجّل باستخدام Google للحصول على رصيد مجاني كل شهر. لا حاجة لبطاقة.",
"insecureContextWarning": "هذا الاتصال غير آمن (HTTP) - قد يتم اعتراض بيانات اعتمادك من قبل المهاجمين إذا تابعت تسجيل الدخول.",
"loginButton": "تسجيل الدخول",
"loginWithGithub": "تسجيل الدخول باستخدام Github",
@@ -255,13 +251,11 @@
"sendResetLink": "إرسال رابط إعادة التعيين",
"signInOrSignUp": "تسجيل الدخول / إنشاء حساب",
"signUp": "إنشاء حساب",
"signUpFreeTierPromo": "جديد هنا؟ {signUp} باستخدام Google للحصول على {credits} رصيد مجاني كل شهر.",
"success": "تم تسجيل الدخول بنجاح",
"termsLink": "شروط الاستخدام",
"termsText": "بالنقر على \"التالي\" أو \"إنشاء حساب\"، فإنك توافق على",
"title": "تسجيل الدخول إلى حسابك",
"useApiKey": "مفتاح API الخاص بـ Comfy",
"useEmailInstead": "استخدم البريد الإلكتروني بدلاً من ذلك",
"userAvatar": "صورة المستخدم"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "هل لديك حساب بالفعل؟",
"emailLabel": "البريد الإلكتروني",
"emailNotEligibleForFreeTier": "التسجيل بالبريد الإلكتروني غير مؤهل للخطة المجانية.",
"emailPlaceholder": "أدخل بريدك الإلكتروني",
"passwordLabel": "كلمة المرور",
"passwordPlaceholder": "أدخل كلمة مرور جديدة",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "الانتقال إلى التحديد"
},
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
"builder": {
"exit": "خروج من البناء",
"exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟",
"exitConfirmTitle": "الخروج من بناء التطبيق؟",
"inputsDesc": "سيتفاعل المستخدمون مع هذه المدخلات ويعدلونها لإنشاء النتائج.",
"inputsExample": "أمثلة: \"تحميل صورة\"، \"موجه نصي\"، \"خطوات\"",
"noInputs": "لم تتم إضافة أي مدخلات بعد",
"noOutputs": "لم تتم إضافة أي عقد إخراج بعد",
"outputsDesc": "وصل عقدة إخراج واحدة على الأقل حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
"promptAddOutputs": "انقر على عقد الإخراج لإضافتها هنا. هذه ستكون النتائج المُولدة.",
"title": "وضع بناء التطبيق"
},
"downloadAll": "تنزيل الكل",
"dragAndDropImage": "اسحب وأسقط صورة",
"graphMode": "وضع الرسم البياني",
@@ -1889,18 +1868,11 @@
"showLinks": "إظهار الروابط"
},
"missingModelsDialog": {
"customModelsInstruction": "ستحتاج إلى العثور عليها وتنزيلها يدويًا. ابحث عنها عبر الإنترنت (جرّب Civitai أو Hugging Face) أو تواصل مع مزود سير العمل الأصلي.",
"customModelsWarning": "بعض هذه النماذج مخصصة ولا نتعرف عليها.",
"description": "يتطلب سير العمل هذا نماذج لم تقم بتنزيلها بعد.",
"doNotAskAgain": "عدم العرض مرة أخرى",
"downloadAll": "تنزيل الكل",
"downloadAvailable": "التنزيل متاح",
"footerDescription": "قم بتنزيل هذه النماذج وضعها في المجلد الصحيح.\nالعُقد التي تفتقد إلى النماذج مميزة باللون الأحمر على اللوحة.",
"gotIt": "حسنًا، فهمت",
"missingModels": "نماذج مفقودة",
"missingModelsMessage": "عند تحميل الرسم البياني، لم يتم العثور على النماذج التالية",
"reEnableInSettings": "إعادة التفعيل في {link}",
"reEnableInSettingsLink": "الإعدادات",
"title": "هذا سير العمل يفتقد إلى النماذج",
"totalSize": "إجمالي حجم التنزيل:"
"reEnableInSettingsLink": "الإعدادات"
},
"missingNodes": {
"cloud": {
@@ -2712,9 +2684,7 @@
"addCreditsLabel": "أضف المزيد من الرصيد في أي وقت",
"benefits": {
"benefit1": "رصيد شهري للعقد الشريكة - تجديد عند الحاجة",
"benefit1FreeTier": "رصيد شهري أكثر، مع إمكانية الشحن في أي وقت",
"benefit2": "حتى 30 دقيقة وقت تشغيل لكل مهمة",
"benefit3": "استخدم نماذجك الخاصة (Creator & Pro)"
"benefit2": "حتى 30 دقيقة وقت تشغيل لكل مهمة"
},
"beta": "نسخة تجريبية",
"billedMonthly": "يتم الفوترة شهريًا",
@@ -2752,21 +2722,6 @@
"description": "اختر الخطة الأنسب لك",
"descriptionWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
"expiresDate": "ينتهي في {date}",
"freeTier": {
"description": "تشمل خطتك المجانية {credits} رصيد شهري لتجربة Comfy Cloud.",
"descriptionGeneric": "تشمل خطتك المجانية رصيدًا شهريًا لتجربة Comfy Cloud.",
"nextRefresh": "سيتم تجديد رصيدك في {date}.",
"outOfCredits": {
"subtitle": "اشترك لفتح الشحن والمزيد",
"title": "لقد نفد رصيدك المجاني"
},
"subscribeCta": "اشترك للمزيد",
"title": "أنت على الخطة المجانية",
"topUpBlocked": {
"title": "افتح الشحن والمزيد"
},
"upgradeCta": "عرض الخطط"
},
"gpuLabel": "RTX 6000 Pro (ذاكرة 96GB VRAM)",
"haveQuestions": "هل لديك أسئلة أو ترغب في معرفة المزيد عن المؤسسات؟",
"invoiceHistory": "سجل الفواتير",
@@ -2777,7 +2732,6 @@
"maxDuration": {
"creator": "30 دقيقة",
"founder": "30 دقيقة",
"free": "٣٠ دقيقة",
"pro": "ساعة واحدة",
"standard": "30 دقيقة"
},
@@ -2850,9 +2804,6 @@
"founder": {
"name": "إصدار المؤسس"
},
"free": {
"name": "مجاني"
},
"pro": {
"name": "احترافي"
},

View File

@@ -7,6 +7,7 @@
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
"comingSoon": "Coming Soon",
"inSubgraph": "in subgraph {name}",
"download": "Download",
"downloadImage": "Download image",
"downloadVideo": "Download video",
@@ -71,7 +72,6 @@
"error": "Error",
"enter": "Enter",
"enterSubgraph": "Enter Subgraph",
"inSubgraph": "in subgraph '{name}'",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
"resizeFromBottomLeft": "Resize from bottom-left corner",
@@ -175,7 +175,6 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"uploadAlreadyInProgress": "Upload already in progress",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
@@ -451,9 +450,6 @@
"import_failed": "Import Failed"
},
"warningTooltip": "This package may have compatibility issues with your current environment"
},
"packInstall": {
"nodeIdRequired": "Node ID is required for installation"
}
},
"importFailed": {
@@ -1888,19 +1884,6 @@
"unlockRatio": "Unlock aspect ratio",
"custom": "Custom"
},
"painter": {
"tool": "Tool",
"brush": "Brush",
"eraser": "Eraser",
"size": "Cursor Size",
"color": "Color Picker",
"hardness": "Hardness",
"width": "Width",
"height": "Height",
"background": "Background",
"clear": "Clear",
"uploadError": "Failed to upload painter image: {status} - {statusText}"
},
"boundingBox": {
"x": "X",
"y": "Y",
@@ -3025,20 +3008,6 @@
"switchToSelectButton": "Switch to Select",
"outputs": "Outputs",
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
},
"builder": {
"title": "App builder mode",
"exit": "Exit builder",
"exitConfirmTitle": "Exit app builder?",
"exitConfirmMessage": "You have unsaved changes that will be lost\nExit without saving?",
"promptAddInputs": "Click on node parameters to add them here as inputs",
"noInputs": "No inputs added yet",
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
"inputsExample": "Examples: “Load image”, “Text prompt”, “Steps”",
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
"noOutputs": "No output nodes added yet",
"outputsDesc": "Connect at least one output node so users can see results after running.",
"outputsExample": "Examples: “Save Image” or “Save Video”"
}
},
"missingNodes": {
@@ -3153,23 +3122,7 @@
"errorHelpGithub": "submit a GitHub issue",
"errorHelpSupport": "contact our support",
"resetToDefault": "Reset to default",
"resetAllParameters": "Reset all parameters",
"missingNodePacks": {
"title": "Missing Node Packs",
"unsupportedTitle": "Unsupported Node Packs",
"cloudMessage": "This workflow requires custom nodes not yet available on Comfy Cloud.",
"ossMessage": "This workflow uses custom nodes you haven't installed yet.",
"installAll": "Install All",
"installNodePack": "Install node pack",
"unknownPack": "Unknown pack",
"installing": "Installing...",
"installed": "Installed",
"applyChanges": "Apply Changes",
"searchInManager": "Search in Node Manager",
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"
}
"resetAllParameters": "Reset all parameters"
},
"errorOverlay": {
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "y",
"backToLogin": "Volver al inicio de sesión",
"backToSocialLogin": "Regístrate con Google o Github en su lugar",
"confirmPasswordLabel": "Confirmar contraseña",
"confirmPasswordPlaceholder": "Ingresa la misma contraseña nuevamente",
"didntReceiveEmail": "¿No recibiste el correo? Contáctanos en",
@@ -234,9 +233,6 @@
"failed": "Inicio de sesión fallido",
"forgotPassword": "¿Olvidaste tu contraseña?",
"forgotPasswordError": "No se pudo enviar el correo electrónico para restablecer la contraseña",
"freeTierBadge": "Elegible para el plan gratuito",
"freeTierDescription": "Regístrate con Google para obtener {credits} créditos gratis cada mes. No se necesita tarjeta.",
"freeTierDescriptionGeneric": "Regístrate con Google para obtener créditos gratis cada mes. No se necesita tarjeta.",
"insecureContextWarning": "Esta conexión no es segura (HTTP): tus credenciales pueden ser interceptadas por atacantes si continúas con el inicio de sesión.",
"loginButton": "Iniciar sesión",
"loginWithGithub": "Iniciar sesión con Github",
@@ -255,13 +251,11 @@
"sendResetLink": "Enviar enlace de restablecimiento",
"signInOrSignUp": "Iniciar sesión / Registrarse",
"signUp": "Regístrate",
"signUpFreeTierPromo": "¿Nuevo aquí? {signUp} con Google para obtener {credits} créditos gratis cada mes.",
"success": "Inicio de sesión exitoso",
"termsLink": "Términos de uso",
"termsText": "Al hacer clic en \"Siguiente\" o \"Registrarse\", aceptas nuestros",
"title": "Inicia sesión en tu cuenta",
"useApiKey": "Clave API de Comfy",
"useEmailInstead": "Usar correo electrónico en su lugar",
"userAvatar": "Avatar de usuario"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"emailLabel": "Correo electrónico",
"emailNotEligibleForFreeTier": "El registro por correo electrónico no es elegible para el plan gratuito.",
"emailPlaceholder": "Ingresa tu correo electrónico",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa una nueva contraseña",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "Cambiar a Seleccionar"
},
"beta": "Modo App Beta - Enviar comentarios",
"builder": {
"exit": "Salir del constructor",
"exitConfirmMessage": "Tienes cambios sin guardar que se perderán\n¿Salir sin guardar?",
"exitConfirmTitle": "¿Salir del constructor de aplicaciones?",
"inputsDesc": "Los usuarios interactuarán y ajustarán estos para generar sus resultados.",
"inputsExample": "Ejemplos: “Cargar imagen”, “Prompt de texto”, “Pasos”",
"noInputs": "Aún no se han agregado entradas",
"noOutputs": "Aún no se han agregado nodos de salida",
"outputsDesc": "Conecta al menos un nodo de salida para que los usuarios vean los resultados después de ejecutar.",
"outputsExample": "Ejemplos: “Guardar imagen” o “Guardar video”",
"promptAddInputs": "Haz clic en los parámetros del nodo para agregarlos aquí como entradas",
"promptAddOutputs": "Haz clic en los nodos de salida para agregarlos aquí. Estos serán los resultados generados.",
"title": "Modo constructor de aplicaciones"
},
"downloadAll": "Descargar todo",
"dragAndDropImage": "Arrastra y suelta una imagen",
"graphMode": "Modo gráfico",
@@ -1889,18 +1868,11 @@
"showLinks": "Mostrar enlaces"
},
"missingModelsDialog": {
"customModelsInstruction": "Tendrás que encontrarlos y descargarlos manualmente. Búscalos en línea (prueba Civitai o Hugging Face) o contacta al proveedor original del flujo de trabajo.",
"customModelsWarning": "Algunos de estos son modelos personalizados que no reconocemos.",
"description": "Este flujo de trabajo requiere modelos que aún no has descargado.",
"doNotAskAgain": "No mostrar esto de nuevo",
"downloadAll": "Descargar todo",
"downloadAvailable": "Descargar disponibles",
"footerDescription": "Descarga y coloca estos modelos en la carpeta correcta.\nLos nodos con modelos faltantes están resaltados en rojo en el lienzo.",
"gotIt": "Entendido",
"missingModels": "Modelos faltantes",
"missingModelsMessage": "Al cargar el gráfico, no se encontraron los siguientes modelos",
"reEnableInSettings": "Vuelve a habilitar en {link}",
"reEnableInSettingsLink": "Configuración",
"title": "Faltan modelos en este flujo de trabajo",
"totalSize": "Tamaño total de descarga:"
"reEnableInSettingsLink": "Configuración"
},
"missingNodes": {
"cloud": {
@@ -2712,9 +2684,7 @@
"addCreditsLabel": "Agrega más créditos cuando quieras",
"benefits": {
"benefit1": "Créditos mensuales para Nodos de Socio — recarga cuando sea necesario",
"benefit1FreeTier": "Más créditos mensuales, recarga en cualquier momento",
"benefit2": "Hasta 30 min de tiempo de ejecución por trabajo",
"benefit3": "Usa tus propios modelos (Creator & Pro)"
"benefit2": "Hasta 30 min de tiempo de ejecución por trabajo"
},
"beta": "BETA",
"billedMonthly": "Facturado mensualmente",
@@ -2752,21 +2722,6 @@
"description": "Elige el mejor plan para ti",
"descriptionWorkspace": "Elige el mejor plan para tu espacio de trabajo",
"expiresDate": "Caduca el {date}",
"freeTier": {
"description": "Tu plan gratuito incluye {credits} créditos cada mes para probar Comfy Cloud.",
"descriptionGeneric": "Tu plan gratuito incluye una asignación mensual de créditos para probar Comfy Cloud.",
"nextRefresh": "Tus créditos se renovarán el {date}.",
"outOfCredits": {
"subtitle": "Suscríbete para desbloquear recargas y más",
"title": "Te has quedado sin créditos gratuitos"
},
"subscribeCta": "Suscríbete para más",
"title": "Estás en el plan gratuito",
"topUpBlocked": {
"title": "Desbloquea recargas y más"
},
"upgradeCta": "Ver planes"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "¿Tienes preguntas o buscas soluciones empresariales?",
"invoiceHistory": "Historial de facturas",
@@ -2777,7 +2732,6 @@
"maxDuration": {
"creator": "30 min",
"founder": "30 min",
"free": "30 min",
"pro": "1 h",
"standard": "30 min"
},
@@ -2850,9 +2804,6 @@
"founder": {
"name": "Edición Fundador"
},
"free": {
"name": "Gratis"
},
"pro": {
"name": "Pro"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "و",
"backToLogin": "بازگشت به ورود",
"backToSocialLogin": "ثبت‌نام با Google یا Github",
"confirmPasswordLabel": "تأیید رمز عبور",
"confirmPasswordPlaceholder": "رمز عبور را مجدداً وارد کنید",
"didntReceiveEmail": "ایمیلی دریافت نکردید؟ با ما تماس بگیرید:",
@@ -234,9 +233,6 @@
"failed": "ورود ناموفق بود",
"forgotPassword": "رمز عبور را فراموش کرده‌اید؟",
"forgotPasswordError": "ارسال ایمیل بازیابی رمز عبور ناموفق بود",
"freeTierBadge": "واجد شرایط طرح رایگان",
"freeTierDescription": "با ثبت‌نام از طریق Google هر ماه {credits} اعتبار رایگان دریافت کنید. نیاز به کارت نیست.",
"freeTierDescriptionGeneric": "با ثبت‌نام از طریق Google هر ماه اعتبار رایگان دریافت کنید. نیاز به کارت نیست.",
"insecureContextWarning": "این اتصال ناامن است (HTTP) - در صورت ادامه ورود، اطلاعات شما ممکن است توسط مهاجمان رهگیری شود.",
"loginButton": "ورود",
"loginWithGithub": "ورود با Github",
@@ -255,13 +251,11 @@
"sendResetLink": "ارسال لینک بازیابی",
"signInOrSignUp": "ورود / ثبت‌نام",
"signUp": "ثبت‌نام",
"signUpFreeTierPromo": "جدید هستید؟ با {signUp} از طریق Google هر ماه {credits} اعتبار رایگان دریافت کنید.",
"success": "ورود موفقیت‌آمیز بود",
"termsLink": "شرایط استفاده",
"termsText": "با کلیک بر روی «بعدی» یا «ثبت‌نام»، شما با",
"title": "ورود به حساب کاربری",
"useApiKey": "کلید Comfy API",
"useEmailInstead": "استفاده از ایمیل به جای آن",
"userAvatar": "آواتار کاربر"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "قبلاً حساب کاربری دارید؟",
"emailLabel": "ایمیل",
"emailNotEligibleForFreeTier": "ثبت‌نام با ایمیل شامل طرح رایگان نمی‌شود.",
"emailPlaceholder": "ایمیل خود را وارد کنید",
"passwordLabel": "رمز عبور",
"passwordPlaceholder": "رمز عبور جدید را وارد کنید",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "رفتن به انتخاب"
},
"beta": "حالت برنامه بتا - ارسال بازخورد",
"builder": {
"exit": "خروج از حالت ساخت",
"exitConfirmMessage": "تغییرات ذخیره‌نشده شما از بین خواهد رفت\nخروج بدون ذخیره؟",
"exitConfirmTitle": "خروج از حالت ساخت اپلیکیشن؟",
"inputsDesc": "کاربران می‌توانند این موارد را تنظیم کنند تا خروجی مورد نظر خود را تولید نمایند.",
"inputsExample": "مثال‌ها: «بارگذاری تصویر»، «متن راهنما»، «تعداد مراحل»",
"noInputs": "هنوز ورودی‌ای اضافه نشده است",
"noOutputs": "هنوز گره خروجی اضافه نشده است",
"outputsDesc": "حداقل یک گره خروجی متصل کنید تا کاربران پس از اجرا نتایج را مشاهده کنند.",
"outputsExample": "مثال‌ها: «ذخیره تصویر» یا «ذخیره ویدیو»",
"promptAddInputs": "برای افزودن پارامترها به عنوان ورودی، روی پارامترهای گره کلیک کنید",
"promptAddOutputs": "برای افزودن خروجی، روی گره‌های خروجی کلیک کنید. این‌ها نتایج تولیدشده خواهند بود.",
"title": "حالت ساخت اپلیکیشن"
},
"downloadAll": "دانلود همه",
"dragAndDropImage": "تصویر را بکشید و رها کنید",
"graphMode": "حالت گراف",
@@ -1889,18 +1868,11 @@
"showLinks": "نمایش پیوندها"
},
"missingModelsDialog": {
"customModelsInstruction": "باید این مدل‌ها را به صورت دستی پیدا و دانلود کنید. آن‌ها را به صورت آنلاین جستجو کنید (Civitai یا Hugging Face را امتحان کنید) یا با ارائه‌دهنده اصلی گردش‌کار تماس بگیرید.",
"customModelsWarning": "برخی از این مدل‌ها سفارشی هستند و ما آن‌ها را نمی‌شناسیم.",
"description": "این گردش‌کار به مدل‌هایی نیاز دارد که هنوز آن‌ها را دانلود نکرده‌اید.",
"doNotAskAgain": "دیگر نمایش داده نشود",
"downloadAll": "دانلود همه",
"downloadAvailable": "دانلود موجود",
"footerDescription": "این مدل‌ها را دانلود کرده و در پوشه صحیح قرار دهید.\nگرههایی که مدل آن‌ها موجود نیست، روی بوم به رنگ قرمز نمایش داده می‌شوند.",
"gotIt": "متوجه شدم",
"missingModels": "مدل‌های مفقود",
"missingModelsMessage": "هنگام بارگذاری گراف، مدل‌های زیر یافت نشدند",
"reEnableInSettings": "فعال‌سازی مجدد در {link}",
"reEnableInSettingsLink": "تنظیمات",
"title": "این گردش‌کار فاقد مدل‌ها است",
"totalSize": "حجم کل دانلود:"
"reEnableInSettingsLink": "تنظیمات"
},
"missingNodes": {
"cloud": {
@@ -2724,9 +2696,7 @@
"addCreditsLabel": "هر زمان اعتبار بیشتری اضافه کنید",
"benefits": {
"benefit1": "۱۰ دلار اعتبار ماهانه برای Partner Nodes — در صورت نیاز شارژ کنید",
"benefit1FreeTier": "اعتبار ماهانه بیشتر، شارژ مجدد در هر زمان",
"benefit2": "تا ۳۰ دقیقه زمان اجرا برای هر کار",
"benefit3": "امکان استفاده از مدل‌های شخصی (Creator و Pro)"
"benefit2": "تا ۳۰ دقیقه زمان اجرا برای هر کار"
},
"beta": "بتا",
"billedMonthly": "صورتحساب ماهانه",
@@ -2764,21 +2734,6 @@
"description": "بهترین طرح را برای خود انتخاب کنید",
"descriptionWorkspace": "بهترین طرح را برای فضای کاری خود انتخاب کنید",
"expiresDate": "انقضا در {date}",
"freeTier": {
"description": "طرح رایگان شما شامل {credits} اعتبار در هر ماه برای استفاده از Comfy Cloud است.",
"descriptionGeneric": "طرح رایگان شما شامل اعتبار ماهانه برای استفاده از Comfy Cloud است.",
"nextRefresh": "اعتبار شما در تاریخ {date} به‌روزرسانی می‌شود.",
"outOfCredits": {
"subtitle": "با اشتراک، امکان شارژ مجدد و امکانات بیشتر را فعال کنید",
"title": "اعتبار رایگان شما تمام شده است"
},
"subscribeCta": "اشتراک برای اعتبار بیشتر",
"title": "شما در طرح رایگان هستید",
"topUpBlocked": {
"title": "امکان شارژ مجدد و امکانات بیشتر را فعال کنید"
},
"upgradeCta": "مشاهده طرح‌ها"
},
"gpuLabel": "RTX 6000 Pro (۹۶ گیگابایت VRAM)",
"haveQuestions": "سوالی دارید یا به دنبال راهکار سازمانی هستید؟",
"invoiceHistory": "تاریخچه فاکتورها",
@@ -2789,7 +2744,6 @@
"maxDuration": {
"creator": "۳۰ دقیقه",
"founder": "۳۰ دقیقه",
"free": "۳۰ دقیقه",
"pro": "۱ ساعت",
"standard": "۳۰ دقیقه"
},
@@ -2862,9 +2816,6 @@
"founder": {
"name": "نسخه بنیان‌گذاران"
},
"free": {
"name": "رایگان"
},
"pro": {
"name": "حرفه‌ای"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "et",
"backToLogin": "Retour à la connexion",
"backToSocialLogin": "Inscrivez-vous avec Google ou Github à la place",
"confirmPasswordLabel": "Confirmer le mot de passe",
"confirmPasswordPlaceholder": "Entrez à nouveau le même mot de passe",
"didntReceiveEmail": "Vous n'avez pas reçu d'e-mail ? Contactez-nous à",
@@ -234,9 +233,6 @@
"failed": "Échec de la connexion",
"forgotPassword": "Mot de passe oublié?",
"forgotPasswordError": "Échec de l'envoi de l'e-mail de réinitialisation du mot de passe",
"freeTierBadge": "Éligible à loffre gratuite",
"freeTierDescription": "Inscrivez-vous avec Google pour obtenir {credits} crédits gratuits chaque mois. Aucune carte requise.",
"freeTierDescriptionGeneric": "Inscrivez-vous avec Google pour obtenir des crédits gratuits chaque mois. Aucune carte requise.",
"insecureContextWarning": "Cette connexion n'est pas sécurisée (HTTP) - vos identifiants pourraient être interceptés par des attaquants si vous continuez à vous connecter.",
"loginButton": "Se connecter",
"loginWithGithub": "Se connecter avec Github",
@@ -255,13 +251,11 @@
"sendResetLink": "Envoyer le lien de réinitialisation",
"signInOrSignUp": "Se connecter / Sinscrire",
"signUp": "S'inscrire",
"signUpFreeTierPromo": "Nouveau ici ? {signUp} avec Google pour obtenir {credits} crédits gratuits chaque mois.",
"success": "Connexion réussie",
"termsLink": "Conditions d'utilisation",
"termsText": "En cliquant sur \"Suivant\" ou \"S'inscrire\", vous acceptez nos",
"title": "Connectez-vous à votre compte",
"useApiKey": "Clé API Comfy",
"useEmailInstead": "Utiliser le-mail à la place",
"userAvatar": "Avatar utilisateur"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "Vous avez déjà un compte?",
"emailLabel": "Email",
"emailNotEligibleForFreeTier": "Linscription par e-mail nest pas éligible à loffre gratuite.",
"emailPlaceholder": "Entrez votre email",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrez un nouveau mot de passe",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "Passer à Sélectionner"
},
"beta": "Mode App Bêta - Donnez votre avis",
"builder": {
"exit": "Quitter le mode créateur",
"exitConfirmMessage": "Vous avez des modifications non enregistrées qui seront perdues\nQuitter sans enregistrer ?",
"exitConfirmTitle": "Quitter le créateur dapplication ?",
"inputsDesc": "Les utilisateurs interagiront avec ces paramètres pour générer leurs résultats.",
"inputsExample": "Exemples : « Charger une image », « Prompt texte », « Étapes »",
"noInputs": "Aucune entrée ajoutée pour le moment",
"noOutputs": "Aucun nœud de sortie ajouté pour le moment",
"outputsDesc": "Connectez au moins un nœud de sortie pour que les utilisateurs voient les résultats après lexécution.",
"outputsExample": "Exemples : « Enregistrer limage » ou « Enregistrer la vidéo »",
"promptAddInputs": "Cliquez sur les paramètres du nœud pour les ajouter ici comme entrées",
"promptAddOutputs": "Cliquez sur les nœuds de sortie pour les ajouter ici. Ce seront les résultats générés.",
"title": "Mode créateur dapplication"
},
"downloadAll": "Tout télécharger",
"dragAndDropImage": "Glissez-déposez une image",
"graphMode": "Mode graphique",
@@ -1889,18 +1868,11 @@
"showLinks": "Afficher les liens"
},
"missingModelsDialog": {
"customModelsInstruction": "Vous devrez les trouver et les télécharger manuellement. Cherchez-les en ligne (essayez Civitai ou Hugging Face) ou contactez le créateur du workflow d'origine.",
"customModelsWarning": "Certains de ces modèles sont personnalisés et nous ne les reconnaissons pas.",
"description": "Ce workflow nécessite des modèles que vous n'avez pas encore téléchargés.",
"doNotAskAgain": "Ne plus afficher ce message",
"downloadAll": "Tout télécharger",
"downloadAvailable": "Téléchargement disponible",
"footerDescription": "Téléchargez et placez ces modèles dans le dossier approprié.\nLes nœuds avec des modèles manquants sont surlignés en rouge sur le canevas.",
"gotIt": "Ok, compris",
"missingModels": "Modèles manquants",
"missingModelsMessage": "Lors du chargement du graphique, les modèles suivants n'ont pas été trouvés",
"reEnableInSettings": "Réactiver dans {link}",
"reEnableInSettingsLink": "Paramètres",
"title": "Ce workflow est incomplet : modèles manquants",
"totalSize": "Taille totale du téléchargement :"
"reEnableInSettingsLink": "Paramètres"
},
"missingNodes": {
"cloud": {
@@ -2712,9 +2684,7 @@
"addCreditsLabel": "Ajoutez des crédits à tout moment",
"benefits": {
"benefit1": "Crédits mensuels pour les Nœuds Partenaires — rechargez si nécessaire",
"benefit1FreeTier": "Plus de crédits mensuels, recharge à tout moment",
"benefit2": "Jusqu'à 30 min d'exécution par tâche",
"benefit3": "Utilisez vos propres modèles (Creator & Pro)"
"benefit2": "Jusqu'à 30 min d'exécution par tâche"
},
"beta": "BÊTA",
"billedMonthly": "Facturé mensuellement",
@@ -2752,21 +2722,6 @@
"description": "Choisissez le forfait qui vous convient",
"descriptionWorkspace": "Choisissez la meilleure offre pour votre espace de travail",
"expiresDate": "Expire le {date}",
"freeTier": {
"description": "Votre plan gratuit inclut {credits} crédits chaque mois pour essayer Comfy Cloud.",
"descriptionGeneric": "Votre plan gratuit inclut une allocation mensuelle de crédits pour essayer Comfy Cloud.",
"nextRefresh": "Vos crédits seront renouvelés le {date}.",
"outOfCredits": {
"subtitle": "Abonnez-vous pour débloquer les recharges et plus encore",
"title": "Vous navez plus de crédits gratuits"
},
"subscribeCta": "Abonnez-vous pour plus",
"title": "Vous êtes sur le plan Gratuit",
"topUpBlocked": {
"title": "Débloquez les recharges et plus encore"
},
"upgradeCta": "Voir les offres"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "Des questions ou besoin d'une offre entreprise ?",
"invoiceHistory": "Historique des factures",
@@ -2777,7 +2732,6 @@
"maxDuration": {
"creator": "30 min",
"founder": "30 min",
"free": "30 min",
"pro": "1 h",
"standard": "30 min"
},
@@ -2850,9 +2804,6 @@
"founder": {
"name": "Édition Fondateur"
},
"free": {
"name": "Gratuit"
},
"pro": {
"name": "Pro"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "および",
"backToLogin": "ログインに戻る",
"backToSocialLogin": "GoogleまたはGithubでサインアップする",
"confirmPasswordLabel": "パスワードの確認",
"confirmPasswordPlaceholder": "もう一度同じパスワードを入力してください",
"didntReceiveEmail": "メールが届きませんか?こちらまでご連絡ください:",
@@ -234,9 +233,6 @@
"failed": "ログイン失敗",
"forgotPassword": "パスワードを忘れましたか?",
"forgotPasswordError": "パスワードリセット用メールの送信に失敗しました",
"freeTierBadge": "無料プラン対象",
"freeTierDescription": "Googleでサインアップすると、毎月{credits}の無料クレジットがもらえます。クレジットカード不要。",
"freeTierDescriptionGeneric": "Googleでサインアップすると、毎月無料クレジットがもらえます。クレジットカード不要。",
"insecureContextWarning": "この接続は安全ではありませんHTTP- このままログインを続けると、認証情報が攻撃者に傍受される可能性があります。",
"loginButton": "ログイン",
"loginWithGithub": "Githubでログイン",
@@ -255,13 +251,11 @@
"sendResetLink": "リセットリンクを送信",
"signInOrSignUp": "サインイン / サインアップ",
"signUp": "サインアップ",
"signUpFreeTierPromo": "初めての方はこちら。Googleで{signUp}して、毎月{credits}の無料クレジットを獲得しましょう。",
"success": "ログイン成功",
"termsLink": "利用規約",
"termsText": "「次へ」または「サインアップ」をクリックすると、私たちの",
"title": "アカウントにログインする",
"useApiKey": "Comfy APIキー",
"useEmailInstead": "メールアドレスを使用する",
"userAvatar": "ユーザーアバター"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
"emailLabel": "メール",
"emailNotEligibleForFreeTier": "メールでのサインアップは無料プランの対象外です。",
"emailPlaceholder": "メールアドレスを入力してください",
"passwordLabel": "パスワード",
"passwordPlaceholder": "新しいパスワードを入力してください",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "選択に切り替え"
},
"beta": "アプリモード ベータ版 - フィードバックを送る",
"builder": {
"exit": "ビルダーを終了",
"exitConfirmMessage": "保存されていない変更は失われます。\n保存せずに終了しますか",
"exitConfirmTitle": "アプリビルダーを終了しますか?",
"inputsDesc": "ユーザーはこれらを操作して出力を生成します。",
"inputsExample": "例:「画像を読み込む」「テキストプロンプト」「ステップ数」",
"noInputs": "まだ入力が追加されていません",
"noOutputs": "まだ出力ノードが追加されていません",
"outputsDesc": "少なくとも1つの出力ードを接続すると、ユーザーが実行後に結果を確認できます。",
"outputsExample": "例:「画像を保存」「動画を保存」",
"promptAddInputs": "ノードのパラメータをクリックして、ここに入力として追加してください",
"promptAddOutputs": "出力ノードをクリックしてここに追加してください。これが生成される結果となります。",
"title": "アプリビルダーモード"
},
"downloadAll": "すべてダウンロード",
"dragAndDropImage": "画像をドラッグ&ドロップ",
"graphMode": "グラフモード",
@@ -1889,18 +1868,11 @@
"showLinks": "リンクを表示"
},
"missingModelsDialog": {
"customModelsInstruction": "手動で探してダウンロードする必要があります。オンラインで検索するかCivitaiやHugging Faceを試してください、元のワークフロープロバイダーに連絡してください。",
"customModelsWarning": "これらの中には、認識できないカスタムモデルが含まれています。",
"description": "このワークフローには、まだダウンロードしていないモデルが必要です。",
"doNotAskAgain": "再度表示しない",
"downloadAll": "すべてダウンロード",
"downloadAvailable": "ダウンロード可能",
"footerDescription": "これらのモデルをダウンロードし、正しいフォルダに配置してください。\n不足しているモデルがあるードはキャンバス上で赤く表示されます。",
"gotIt": "了解しました",
"missingModels": "モデルが見つかりません",
"missingModelsMessage": "グラフを読み込む際に、次のモデルが見つかりませんでした",
"reEnableInSettings": "{link}で再有効化",
"reEnableInSettingsLink": "設定",
"title": "このワークフローにはモデルが不足しています",
"totalSize": "合計ダウンロードサイズ:"
"reEnableInSettingsLink": "設定"
},
"missingNodes": {
"cloud": {
@@ -2712,9 +2684,7 @@
"addCreditsLabel": "いつでもクレジット追加可能",
"benefits": {
"benefit1": "パートナーノード用月間クレジット — 必要に応じて追加購入可能",
"benefit1FreeTier": "毎月のクレジット増加、いつでもチャージ可能",
"benefit2": "ジョブあたり最大30分の実行時間",
"benefit3": "独自のモデルを利用可能Creator & Pro"
"benefit2": "ジョブあたり最大30分の実行時間"
},
"beta": "ベータ版",
"billedMonthly": "毎月請求",
@@ -2752,21 +2722,6 @@
"description": "あなたに最適なプランを選択してください",
"descriptionWorkspace": "ワークスペースに最適なプランを選択してください",
"expiresDate": "{date} に期限切れ",
"freeTier": {
"description": "無料プランには、Comfy Cloudをお試しいただける毎月{credits}クレジットが含まれています。",
"descriptionGeneric": "無料プランには、Comfy Cloudをお試しいただける毎月のクレジット枠が含まれています。",
"nextRefresh": "クレジットは{date}にリフレッシュされます。",
"outOfCredits": {
"subtitle": "サブスクリプションでチャージや追加機能を利用しましょう",
"title": "無料クレジットがなくなりました"
},
"subscribeCta": "さらに詳しく",
"title": "無料プランをご利用中です",
"topUpBlocked": {
"title": "チャージや追加機能をアンロック"
},
"upgradeCta": "プランを見る"
},
"gpuLabel": "RTX 6000 Pro96GB VRAM",
"haveQuestions": "ご質問やエンタープライズについてのお問い合わせはこちら",
"invoiceHistory": "請求履歴",
@@ -2777,7 +2732,6 @@
"maxDuration": {
"creator": "30分",
"founder": "30分",
"free": "30分",
"pro": "1時間",
"standard": "30分"
},
@@ -2850,9 +2804,6 @@
"founder": {
"name": "ファウンダーエディション"
},
"free": {
"name": "無料"
},
"pro": {
"name": "プロ"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "및",
"backToLogin": "로그인으로 돌아가기",
"backToSocialLogin": "Google 또는 Github로 가입하기",
"confirmPasswordLabel": "비밀번호 확인",
"confirmPasswordPlaceholder": "동일한 비밀번호를 다시 입력하세요",
"didntReceiveEmail": "이메일을 받지 못하셨나요? 다음으로 문의하세요:",
@@ -234,9 +233,6 @@
"failed": "로그인 실패",
"forgotPassword": "비밀번호를 잊으셨나요?",
"forgotPasswordError": "비밀번호 재설정 이메일 전송에 실패했습니다",
"freeTierBadge": "무료 등급 가능",
"freeTierDescription": "Google로 가입하면 매월 {credits} 무료 크레딧을 받을 수 있습니다. 카드 필요 없음.",
"freeTierDescriptionGeneric": "Google로 가입하면 매월 무료 크레딧을 받을 수 있습니다. 카드 필요 없음.",
"insecureContextWarning": "이 연결은 안전하지 않습니다(HTTP) - 로그인을 계속하면 자격 증명이 공격자에게 가로채질 수 있습니다.",
"loginButton": "로그인",
"loginWithGithub": "Github로 로그인",
@@ -255,13 +251,11 @@
"sendResetLink": "재설정 링크 보내기",
"signInOrSignUp": "로그인 / 회원가입",
"signUp": "가입하기",
"signUpFreeTierPromo": "처음이신가요? Google로 {signUp} 하여 매월 {credits} 무료 크레딧을 받으세요.",
"success": "로그인 성공",
"termsLink": "이용 약관",
"termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의",
"title": "계정에 로그인",
"useApiKey": "Comfy API 키",
"useEmailInstead": "이메일로 계속하기",
"userAvatar": "사용자 아바타"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "이미 계정이 있으신가요?",
"emailLabel": "이메일",
"emailNotEligibleForFreeTier": "이메일 가입은 무료 등급에 해당되지 않습니다.",
"emailPlaceholder": "이메일을 입력하세요",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "새 비밀번호를 입력하세요",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "선택으로 전환"
},
"beta": "앱 모드 베타 - 피드백 보내기",
"builder": {
"exit": "빌더 종료",
"exitConfirmMessage": "저장되지 않은 변경사항이 사라집니다\n저장하지 않고 종료하시겠습니까?",
"exitConfirmTitle": "앱 빌더를 종료할까요?",
"inputsDesc": "사용자가 이 항목을 조정하여 결과를 생성할 수 있습니다.",
"inputsExample": "예시: “이미지 불러오기”, “텍스트 프롬프트”, “스텝 수”",
"noInputs": "아직 입력값이 추가되지 않았습니다",
"noOutputs": "아직 출력 노드가 추가되지 않았습니다",
"outputsDesc": "최소 한 개 이상의 출력 노드를 연결해야 실행 후 결과를 볼 수 있습니다.",
"outputsExample": "예시: “이미지 저장” 또는 “비디오 저장”",
"promptAddInputs": "노드 파라미터를 클릭하여 입력값으로 추가하세요",
"promptAddOutputs": "출력 노드를 클릭하여 여기에 추가하세요. 이들이 생성된 결과가 됩니다.",
"title": "앱 빌더 모드"
},
"downloadAll": "모두 다운로드",
"dragAndDropImage": "이미지를 드래그 앤 드롭하세요",
"graphMode": "그래프 모드",
@@ -1889,18 +1868,11 @@
"showLinks": "링크 표시"
},
"missingModelsDialog": {
"customModelsInstruction": "직접 찾아서 수동으로 다운로드해야 합니다. 온라인에서 검색해보세요(예: Civitai 또는 Hugging Face) 또는 원래 워크플로우 제공자에게 문의하세요.",
"customModelsWarning": "이 중 일부는 인식되지 않는 커스텀 모델입니다.",
"description": "이 워크플로우에는 아직 다운로드하지 않은 모델이 필요합니다.",
"doNotAskAgain": "다시 보지 않기",
"downloadAll": "모두 다운로드",
"downloadAvailable": "다운로드 가능",
"footerDescription": "이 모델들을 다운로드하여 올바른 폴더에 넣으세요.\n모델이 누락된 노드는 캔버스에서 빨간색으로 표시됩니다.",
"gotIt": "확인",
"missingModels": "모델이 없습니다",
"missingModelsMessage": "그래프를 로드할 때 다음 모델을 찾을 수 없었습니다",
"reEnableInSettings": "{link}에서 다시 활성화",
"reEnableInSettingsLink": "설정",
"title": "이 워크플로우에 모델이 누락되었습니다",
"totalSize": "총 다운로드 크기:"
"reEnableInSettingsLink": "설정"
},
"missingNodes": {
"cloud": {
@@ -2712,9 +2684,7 @@
"addCreditsLabel": "언제든지 크레딧 추가 가능",
"benefits": {
"benefit1": "파트너 노드 월간 크레딧 — 필요 시 충전",
"benefit1FreeTier": "더 많은 월간 크레딧, 언제든지 충전 가능",
"benefit2": "작업당 최대 30분 실행 시간",
"benefit3": "직접 모델 가져오기(Creator & Pro)"
"benefit2": "작업당 최대 30분 실행 시간"
},
"beta": "베타",
"billedMonthly": "매월 결제",
@@ -2752,21 +2722,6 @@
"description": "가장 적합한 플랜을 선택하세요",
"descriptionWorkspace": "워크스페이스에 가장 적합한 플랜을 선택하세요",
"expiresDate": "만료일 {date}",
"freeTier": {
"description": "무료 플랜에는 Comfy Cloud를 체험할 수 있도록 매월 {credits} 크레딧이 포함되어 있습니다.",
"descriptionGeneric": "무료 플랜에는 Comfy Cloud를 체험할 수 있는 월간 크레딧이 포함되어 있습니다.",
"nextRefresh": "크레딧은 {date}에 새로 고침됩니다.",
"outOfCredits": {
"subtitle": "충전 및 추가 혜택을 위해 구독하세요",
"title": "무료 크레딧이 모두 소진되었습니다"
},
"subscribeCta": "더 많은 혜택 구독하기",
"title": "무료 플랜을 사용 중입니다",
"topUpBlocked": {
"title": "충전 및 추가 혜택 잠금 해제"
},
"upgradeCta": "플랜 보기"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "질문이 있거나 엔터프라이즈가 궁금하신가요?",
"invoiceHistory": "청구서 기록",
@@ -2777,7 +2732,6 @@
"maxDuration": {
"creator": "30분",
"founder": "30분",
"free": "30분",
"pro": "1시간",
"standard": "30분"
},
@@ -2850,9 +2804,6 @@
"founder": {
"name": "Founder's Edition"
},
"free": {
"name": "무료"
},
"pro": {
"name": "Pro"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "e",
"backToLogin": "Voltar para login",
"backToSocialLogin": "Cadastre-se com Google ou Github em vez disso",
"confirmPasswordLabel": "Confirmar senha",
"confirmPasswordPlaceholder": "Digite a mesma senha novamente",
"didntReceiveEmail": "Não recebeu o e-mail? Entre em contato conosco em",
@@ -234,9 +233,6 @@
"failed": "Falha no login",
"forgotPassword": "Esqueceu a senha?",
"forgotPasswordError": "Falha ao enviar e-mail de redefinição de senha",
"freeTierBadge": "Elegível para o Plano Gratuito",
"freeTierDescription": "Cadastre-se com Google para ganhar {credits} créditos gratuitos todo mês. Não precisa de cartão.",
"freeTierDescriptionGeneric": "Cadastre-se com Google para ganhar créditos gratuitos todo mês. Não precisa de cartão.",
"insecureContextWarning": "Esta conexão é insegura (HTTP) - suas credenciais podem ser interceptadas por invasores se você continuar.",
"loginButton": "Entrar",
"loginWithGithub": "Entrar com Github",
@@ -255,13 +251,11 @@
"sendResetLink": "Enviar link de redefinição",
"signInOrSignUp": "Entrar / Cadastrar-se",
"signUp": "Cadastrar-se",
"signUpFreeTierPromo": "Novo por aqui? {signUp} com Google para ganhar {credits} créditos gratuitos todo mês.",
"success": "Login realizado com sucesso",
"termsLink": "Termos de Uso",
"termsText": "Ao clicar em \"Próximo\" ou \"Cadastrar-se\", você concorda com nossos",
"title": "Faça login na sua conta",
"useApiKey": "Chave de API Comfy",
"useEmailInstead": "Usar e-mail em vez disso",
"userAvatar": "Avatar do usuário"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "Já tem uma conta?",
"emailLabel": "E-mail",
"emailNotEligibleForFreeTier": "Cadastro por e-mail não é elegível para o Plano Gratuito.",
"emailPlaceholder": "Digite seu e-mail",
"passwordLabel": "Senha",
"passwordPlaceholder": "Digite uma nova senha",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "Ir para Selecionar"
},
"beta": "Modo App Beta - Envie seu feedback",
"builder": {
"exit": "Sair do construtor",
"exitConfirmMessage": "Você tem alterações não salvas que serão perdidas\nSair sem salvar?",
"exitConfirmTitle": "Sair do construtor de app?",
"inputsDesc": "Os usuários irão interagir e ajustar estes para gerar seus resultados.",
"inputsExample": "Exemplos: “Carregar imagem”, “Prompt de texto”, “Passos”",
"noInputs": "Nenhuma entrada adicionada ainda",
"noOutputs": "Nenhum nó de saída adicionado ainda",
"outputsDesc": "Conecte pelo menos um nó de saída para que os usuários vejam os resultados após executar.",
"outputsExample": "Exemplos: “Salvar imagem” ou “Salvar vídeo”",
"promptAddInputs": "Clique nos parâmetros do nó para adicioná-los aqui como entradas",
"promptAddOutputs": "Clique nos nós de saída para adicioná-los aqui. Estes serão os resultados gerados.",
"title": "Modo construtor de app"
},
"downloadAll": "Baixar tudo",
"dragAndDropImage": "Arraste e solte uma imagem",
"graphMode": "Modo Gráfico",
@@ -1889,18 +1868,11 @@
"showLinks": "Mostrar Conexões"
},
"missingModelsDialog": {
"customModelsInstruction": "Você precisará encontrá-los e baixá-los manualmente. Procure por eles online (tente Civitai ou Hugging Face) ou entre em contato com o provedor original do fluxo de trabalho.",
"customModelsWarning": "Alguns desses são modelos personalizados que não reconhecemos.",
"description": "Este fluxo de trabalho requer modelos que você ainda não baixou.",
"doNotAskAgain": "Não mostrar novamente",
"downloadAll": "Baixar todos",
"downloadAvailable": "Baixar disponíveis",
"footerDescription": "Baixe e coloque esses modelos na pasta correta.\nNós com modelos ausentes estão destacados em vermelho no canvas.",
"gotIt": "Ok, entendi",
"missingModels": "Modelos ausentes",
"missingModelsMessage": "Ao carregar o grafo, os seguintes modelos não foram encontrados",
"reEnableInSettings": "Reativar em {link}",
"reEnableInSettingsLink": "Configurações",
"title": "Este fluxo de trabalho está sem modelos",
"totalSize": "Tamanho total do download:"
"reEnableInSettingsLink": "Configurações"
},
"missingNodes": {
"cloud": {
@@ -2724,9 +2696,7 @@
"addCreditsLabel": "Adicione mais créditos quando quiser",
"benefits": {
"benefit1": "$10 em créditos mensais para Partner Nodes — recarregue quando necessário",
"benefit1FreeTier": "Mais créditos mensais, recarregue a qualquer momento",
"benefit2": "Até 30 min de execução por tarefa",
"benefit3": "Use seus próprios modelos (Creator & Pro)"
"benefit2": "Até 30 min de execução por tarefa"
},
"beta": "BETA",
"billedMonthly": "Cobrado mensalmente",
@@ -2764,21 +2734,6 @@
"description": "Escolha o melhor plano para você",
"descriptionWorkspace": "Escolha o melhor plano para seu workspace",
"expiresDate": "Expira em {date}",
"freeTier": {
"description": "Seu plano gratuito inclui {credits} créditos por mês para testar o Comfy Cloud.",
"descriptionGeneric": "Seu plano gratuito inclui uma cota mensal de créditos para testar o Comfy Cloud.",
"nextRefresh": "Seus créditos serão renovados em {date}.",
"outOfCredits": {
"subtitle": "Assine para liberar recargas e mais benefícios",
"title": "Você ficou sem créditos gratuitos"
},
"subscribeCta": "Assine para mais",
"title": "Você está no plano Gratuito",
"topUpBlocked": {
"title": "Desbloqueie recargas e mais benefícios"
},
"upgradeCta": "Ver planos"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "Tem dúvidas ou interesse em soluções empresariais?",
"invoiceHistory": "Histórico de faturas",
@@ -2789,7 +2744,6 @@
"maxDuration": {
"creator": "30 min",
"founder": "30 min",
"free": "30 min",
"pro": "1 h",
"standard": "30 min"
},
@@ -2862,9 +2816,6 @@
"founder": {
"name": "Edição do Fundador"
},
"free": {
"name": "Gratuito"
},
"pro": {
"name": "Pro"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "и",
"backToLogin": "Вернуться к входу",
"backToSocialLogin": "Зарегистрируйтесь через Google или Github",
"confirmPasswordLabel": "Подтвердите пароль",
"confirmPasswordPlaceholder": "Введите тот же пароль еще раз",
"didntReceiveEmail": "Не получили письмо? Свяжитесь с нами по адресу",
@@ -234,9 +233,6 @@
"failed": "Вход не удался",
"forgotPassword": "Забыли пароль?",
"forgotPasswordError": "Не удалось отправить письмо для сброса пароля",
"freeTierBadge": "Доступен бесплатный тариф",
"freeTierDescription": "Зарегистрируйтесь через Google и получите {credits} бесплатных кредитов каждый месяц. Карта не требуется.",
"freeTierDescriptionGeneric": "Зарегистрируйтесь через Google и получайте бесплатные кредиты каждый месяц. Карта не требуется.",
"insecureContextWarning": "Это соединение небезопасно (HTTP) — ваши учетные данные могут быть перехвачены злоумышленниками, если вы продолжите вход.",
"loginButton": "Войти",
"loginWithGithub": "Войти через Github",
@@ -255,13 +251,11 @@
"sendResetLink": "Отправить ссылку для сброса",
"signInOrSignUp": "Войти / Зарегистрироваться",
"signUp": "Зарегистрироваться",
"signUpFreeTierPromo": "Впервые здесь? {signUp} через Google и получите {credits} бесплатных кредитов каждый месяц.",
"success": "Вход выполнен успешно",
"termsLink": "Условиями использования",
"termsText": "Нажимая \"Далее\" или \"Зарегистрироваться\", вы соглашаетесь с нашими",
"title": "Войдите в свой аккаунт",
"useApiKey": "Comfy API-ключ",
"useEmailInstead": "Использовать электронную почту",
"userAvatar": "Аватар пользователя"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "Уже есть аккаунт?",
"emailLabel": "Электронная почта",
"emailNotEligibleForFreeTier": "Регистрация по электронной почте не даёт права на бесплатный тариф.",
"emailPlaceholder": "Введите вашу электронную почту",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите новый пароль",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "Переключиться на выбор"
},
"beta": "Режим приложения Бета - Оставить отзыв",
"builder": {
"exit": "Выйти из конструктора",
"exitConfirmMessage": "У вас есть несохранённые изменения, которые будут потеряны\nВыйти без сохранения?",
"exitConfirmTitle": "Выйти из конструктора приложений?",
"inputsDesc": "Пользователи будут взаимодействовать с этими параметрами и настраивать их для генерации результата.",
"inputsExample": "Примеры: «Загрузить изображение», «Текстовый промпт», «Шаги»",
"noInputs": "Входные данные ещё не добавлены",
"noOutputs": "Выходные узлы ещё не добавлены",
"outputsDesc": "Подключите хотя бы один выходной узел, чтобы пользователи видели результаты после запуска.",
"outputsExample": "Примеры: «Сохранить изображение» или «Сохранить видео»",
"promptAddInputs": "Нажмите на параметры узла, чтобы добавить их сюда как входные данные",
"promptAddOutputs": "Нажмите на выходные узлы, чтобы добавить их сюда. Это будут сгенерированные результаты.",
"title": "Режим конструктора приложений"
},
"downloadAll": "Скачать всё",
"dragAndDropImage": "Перетащите изображение",
"graphMode": "Графовый режим",
@@ -1889,18 +1868,11 @@
"showLinks": "Показать связи"
},
"missingModelsDialog": {
"customModelsInstruction": "Вам нужно найти и скачать их вручную. Поискать их можно в интернете (например, на Civitai или Hugging Face) или связаться с автором рабочего процесса.",
"customModelsWarning": "Некоторые из них — это пользовательские модели, которые нам не известны.",
"description": "Для этого рабочего процесса требуются модели, которые вы ещё не скачали.",
"doNotAskAgain": "Больше не показывать это",
"downloadAll": "Скачать всё",
"downloadAvailable": "Доступно для загрузки",
"footerDescription": "Скачайте и поместите эти модели в нужную папку.\nУзлы с отсутствующими моделями выделены красным на холсте.",
"gotIt": "Понятно",
"missingModels": "Отсутствующие модели",
"missingModelsMessage": "При загрузке графа следующие модели не были найдены",
"reEnableInSettings": "Включить снова в {link}",
"reEnableInSettingsLink": "Настройки",
"title": "В этом рабочем процессе отсутствуют модели",
"totalSize": "Общий размер загрузки:"
"reEnableInSettingsLink": "Настройки"
},
"missingNodes": {
"cloud": {
@@ -2712,9 +2684,7 @@
"addCreditsLabel": "Добавляйте кредиты в любое время",
"benefits": {
"benefit1": "Ежемесячные кредиты для Партнёрских узлов — пополняйте по необходимости",
"benefit1FreeTier": "Больше кредитов в месяц, пополнение в любое время",
"benefit2": "До 30 минут выполнения на задание",
"benefit3": "Используйте свои модели (Creator & Pro)"
"benefit2": "До 30 минут выполнения на задание"
},
"beta": "БЕТА",
"billedMonthly": "Оплата ежемесячно",
@@ -2752,21 +2722,6 @@
"description": "Выберите лучший план для себя",
"descriptionWorkspace": "Выберите лучший тариф для вашего рабочего пространства",
"expiresDate": "Истекает {date}",
"freeTier": {
"description": "Ваш бесплатный тариф включает {credits} кредитов каждый месяц для использования Comfy Cloud.",
"descriptionGeneric": "Ваш бесплатный тариф включает ежемесячный лимит кредитов для использования Comfy Cloud.",
"nextRefresh": "Кредиты обновятся {date}.",
"outOfCredits": {
"subtitle": "Оформите подписку для пополнения и других возможностей",
"title": "У вас закончились бесплатные кредиты"
},
"subscribeCta": "Подписаться для большего",
"title": "Вы на бесплатном тарифе",
"topUpBlocked": {
"title": "Откройте пополнение и другие возможности"
},
"upgradeCta": "Посмотреть тарифы"
},
"gpuLabel": "RTX 6000 Pro (96ГБ VRAM)",
"haveQuestions": "Есть вопросы или интересует корпоративное решение?",
"invoiceHistory": "История счетов",
@@ -2777,7 +2732,6 @@
"maxDuration": {
"creator": "30 мин",
"founder": "30 мин",
"free": "30 мин",
"pro": "1 ч",
"standard": "30 мин"
},
@@ -2850,9 +2804,6 @@
"founder": {
"name": "Founder's Edition"
},
"free": {
"name": "Бесплатно"
},
"pro": {
"name": "Pro"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "ve",
"backToLogin": "Girişe dön",
"backToSocialLogin": "Bunun yerine Google veya Github ile kaydolun",
"confirmPasswordLabel": "Şifreyi Onayla",
"confirmPasswordPlaceholder": "Aynı şifreyi tekrar girin",
"didntReceiveEmail": "E-posta almadınız mı? Bize şu adresten ulaşın:",
@@ -234,9 +233,6 @@
"failed": "Giriş başarısız",
"forgotPassword": "Şifrenizi mi unuttunuz?",
"forgotPasswordError": "Şifre sıfırlama e-postası gönderilemedi",
"freeTierBadge": "Ücretsiz Katman Uygun",
"freeTierDescription": "Google ile kaydolun, her ay {credits} ücretsiz kredi kazanın. Kart gerekmez.",
"freeTierDescriptionGeneric": "Google ile kaydolun, her ay ücretsiz kredi kazanın. Kart gerekmez.",
"insecureContextWarning": "Bu bağlantı güvensiz (HTTP) - giriş yapmaya devam ederseniz kimlik bilgileriniz saldırganlar tarafından ele geçirilebilir.",
"loginButton": "Giriş Yap",
"loginWithGithub": "Github ile giriş yap",
@@ -255,13 +251,11 @@
"sendResetLink": "Sıfırlama bağlantısını gönder",
"signInOrSignUp": "Giriş Yap / Kaydol",
"signUp": "Kaydol",
"signUpFreeTierPromo": "Yeni misiniz? Her ay {credits} ücretsiz kredi almak için Google ile {signUp} olun.",
"success": "Giriş başarılı",
"termsLink": "Kullanım Koşullarımızı",
"termsText": "\"İleri\" veya \"Kaydol\" düğmesine tıklayarak,",
"title": "Hesabınıza giriş yapın",
"useApiKey": "Comfy API Anahtarı",
"useEmailInstead": "Bunun yerine e-posta kullan",
"userAvatar": "Kullanıcı Avatarı"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "Zaten bir hesabınız var mı?",
"emailLabel": "E-posta",
"emailNotEligibleForFreeTier": "E-posta ile kayıt Ücretsiz Katman için uygun değildir.",
"emailPlaceholder": "E-postanızı girin",
"passwordLabel": "Şifre",
"passwordPlaceholder": "Yeni şifre girin",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "Seç'e Geç"
},
"beta": "Uygulama Modu Beta - Geri Bildirim Verin",
"builder": {
"exit": "Oluşturucudan çık",
"exitConfirmMessage": "Kaydedilmemiş değişiklikleriniz kaybolacak\nKaydetmeden çıkılsın mı?",
"exitConfirmTitle": "Uygulama oluşturucudan çıkılsın mı?",
"inputsDesc": "Kullanıcılar bunlarla etkileşime geçip ayarlayarak çıktılarını oluşturacak.",
"inputsExample": "Örnekler: “Resim yükle”, “Metin istemi”, “Adımlar”",
"noInputs": "Henüz giriş eklenmedi",
"noOutputs": "Henüz çıktı düğümü eklenmedi",
"outputsDesc": "Kullanıcıların çalıştırdıktan sonra sonuçları görebilmesi için en az bir çıktı düğümü bağlayın.",
"outputsExample": "Örnekler: “Resmi Kaydet” veya “Videoyu Kaydet”",
"promptAddInputs": "Girdi olarak eklemek için düğüm parametrelerine tıklayın",
"promptAddOutputs": ıktı olarak eklemek için çıktı düğümlerine tıklayın. Bunlar oluşturulan sonuçlar olacak.",
"title": "Uygulama oluşturucu modu"
},
"downloadAll": "Tümünü İndir",
"dragAndDropImage": "Bir görseli sürükleyip bırakın",
"graphMode": "Grafik Modu",
@@ -1889,18 +1868,11 @@
"showLinks": "Bağlantıları Göster"
},
"missingModelsDialog": {
"customModelsInstruction": "Bunları manuel olarak bulup indirmeniz gerekecek. İnternette arayın (Civitai veya Hugging Face deneyin) ya da orijinal iş akışı sağlayıcısıyla iletişime geçin.",
"customModelsWarning": "Bunlardan bazıları tanımadığımız özel modellerdir.",
"description": "Bu iş akışı, henüz indirmediğiniz modellere ihtiyaç duyuyor.",
"doNotAskAgain": "Bunu bir daha gösterme",
"downloadAll": "Hepsini indir",
"downloadAvailable": "İndirilebilir",
"footerDescription": "Bu modelleri indirip doğru klasöre yerleştirin.\nEksik modeli olan düğümler tuvalde kırmızı ile vurgulanır.",
"gotIt": "Tamam, anladım",
"missingModels": "Eksik Modeller",
"missingModelsMessage": "Grafik yüklenirken aşağıdaki modeller bulunamadı",
"reEnableInSettings": "{link} içinde tekrar etkinleştir",
"reEnableInSettingsLink": "Ayarlar",
"title": "Bu iş akışında eksik modeller var",
"totalSize": "Toplam indirme boyutu:"
"reEnableInSettingsLink": "Ayarlar"
},
"missingNodes": {
"cloud": {
@@ -2712,9 +2684,7 @@
"addCreditsLabel": "İstediğiniz zaman kredi ekleyin",
"benefits": {
"benefit1": "Partner Düğümleri için aylık krediler — ihtiyaç duyulduğunda yükleyin",
"benefit1FreeTier": "Daha fazla aylık kredi, istediğiniz zaman yükleyin",
"benefit2": "İş başına en fazla 30 dakika çalışma süresi",
"benefit3": "Kendi modellerinizi getirin (Creator & Pro)"
"benefit2": "İş başına en fazla 30 dakika çalışma süresi"
},
"beta": "BETA",
"billedMonthly": "Aylık faturalandırılır",
@@ -2752,21 +2722,6 @@
"description": "Sizin için en iyi planı seçin",
"descriptionWorkspace": "Çalışma alanınız için en iyi planı seçin",
"expiresDate": "{date} tarihinde sona erer",
"freeTier": {
"description": "Ücretsiz planınız, Comfy Cloud'u denemek için her ay {credits} kredi içerir.",
"descriptionGeneric": "Ücretsiz planınız, Comfy Cloud'u denemek için aylık kredi hakkı içerir.",
"nextRefresh": "Kredileriniz {date} tarihinde yenilenecek.",
"outOfCredits": {
"subtitle": "Yeniden yükleme ve daha fazlasının kilidini açmak için abone olun",
"title": "Ücretsiz kredileriniz bitti"
},
"subscribeCta": "Daha fazlası için abone olun",
"title": "Ücretsiz plandasınız",
"topUpBlocked": {
"title": "Yeniden yükleme ve daha fazlasının kilidini açın"
},
"upgradeCta": "Planları görüntüle"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "Sorularınız mı var veya kurumsal çözüm mü arıyorsunuz?",
"invoiceHistory": "Fatura geçmişi",
@@ -2777,7 +2732,6 @@
"maxDuration": {
"creator": "30 dk",
"founder": "30 dk",
"free": "30 dk",
"pro": "1 sa",
"standard": "30 dk"
},
@@ -2850,9 +2804,6 @@
"founder": {
"name": "Kurucu Sürümü"
},
"free": {
"name": "Ücretsiz"
},
"pro": {
"name": "Pro"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "以及",
"backToLogin": "返回登入",
"backToSocialLogin": "改用 Google 或 Github 註冊",
"confirmPasswordLabel": "確認密碼",
"confirmPasswordPlaceholder": "請再次輸入相同密碼",
"didntReceiveEmail": "沒有收到電子郵件?請聯絡我們:",
@@ -234,9 +233,6 @@
"failed": "登入失敗",
"forgotPassword": "忘記密碼?",
"forgotPasswordError": "密碼重設郵件發送失敗",
"freeTierBadge": "符合免費方案資格",
"freeTierDescription": "使用 Google 註冊,每月可獲得 {credits} 免費點數。無需信用卡。",
"freeTierDescriptionGeneric": "使用 Google 註冊,每月可獲得免費點數。無需信用卡。",
"insecureContextWarning": "此連線不安全HTTP。如果您繼續登入您的憑證可能會被攻擊者攔截。",
"loginButton": "登入",
"loginWithGithub": "使用 Github 登入",
@@ -255,13 +251,11 @@
"sendResetLink": "發送重設連結",
"signInOrSignUp": "登入 / 註冊",
"signUp": "註冊",
"signUpFreeTierPromo": "新用戶?{signUp} 使用 Google 註冊,每月獲得 {credits} 免費點數。",
"success": "登入成功",
"termsLink": "使用條款",
"termsText": "點擊「下一步」或「註冊」即表示您同意我們的",
"title": "登入您的帳戶",
"useApiKey": "Comfy API 金鑰",
"useEmailInstead": "改用電子郵件",
"userAvatar": "用戶頭像"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "已經有帳戶?",
"emailLabel": "電子郵件",
"emailNotEligibleForFreeTier": "電子郵件註冊不符合免費方案資格。",
"emailPlaceholder": "請輸入您的電子郵件",
"passwordLabel": "密碼",
"passwordPlaceholder": "請輸入新密碼",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "切換到選擇"
},
"beta": "App 模式 Beta - 提供回饋",
"builder": {
"exit": "離開建構器",
"exitConfirmMessage": "您有尚未儲存的變更將會遺失\n確定要不儲存直接離開嗎",
"exitConfirmTitle": "要離開應用程式建構器嗎?",
"inputsDesc": "使用者可調整這些參數以產生輸出。",
"inputsExample": "例如:「載入圖像」、「文字提示」、「步數」",
"noInputs": "尚未新增任何輸入",
"noOutputs": "尚未新增任何輸出節點",
"outputsDesc": "請至少連接一個輸出節點,讓使用者在執行後能看到結果。",
"outputsExample": "例如:「儲存圖像」或「儲存影片」",
"promptAddInputs": "點擊節點參數,將其新增為輸入",
"promptAddOutputs": "點擊輸出節點,將其新增於此。這些將是產生的結果。",
"title": "應用程式建構模式"
},
"downloadAll": "全部下載",
"dragAndDropImage": "拖曳圖片到此",
"graphMode": "圖形模式",
@@ -1889,18 +1868,11 @@
"showLinks": "顯示連結"
},
"missingModelsDialog": {
"customModelsInstruction": "您需要自行尋找並下載這些模型。請在網路上搜尋(可嘗試 Civitai 或 Hugging Face或聯絡原始工作流程提供者。",
"customModelsWarning": "其中有些是我們無法識別的自訂模型。",
"description": "此工作流程需要您尚未下載的模型。",
"doNotAskAgain": "不要再顯示此訊息",
"downloadAll": "全部下載",
"downloadAvailable": "下載可用項目",
"footerDescription": "請下載並將這些模型放置在正確的資料夾中。\n缺少模型的節點會在畫布上以紅色標示。",
"gotIt": "知道了",
"missingModels": "缺少模型",
"missingModelsMessage": "載入圖形時,找不到以下模型",
"reEnableInSettings": "請在{link}中重新啟用",
"reEnableInSettingsLink": "設定",
"title": "此工作流程缺少模型",
"totalSize": "總下載大小:"
"reEnableInSettingsLink": "設定"
},
"missingNodes": {
"cloud": {
@@ -2712,9 +2684,7 @@
"addCreditsLabel": "隨時可儲值點數",
"benefits": {
"benefit1": "合作節點每月點數 — 需要時可隨時加值",
"benefit1FreeTier": "每月更多點數,隨時加值",
"benefit2": "每項任務最多運行 30 分鐘",
"benefit3": "可自帶模型Creator & Pro"
"benefit2": "每項任務最多運行 30 分鐘"
},
"beta": "測試版",
"billedMonthly": "每月收費",
@@ -2752,21 +2722,6 @@
"description": "選擇最適合您的方案",
"descriptionWorkspace": "為您的工作區選擇最佳方案",
"expiresDate": "將於 {date} 到期",
"freeTier": {
"description": "您的免費方案每月包含 {credits} 點數,可體驗 Comfy Cloud。",
"descriptionGeneric": "您的免費方案每月包含點數額度,可體驗 Comfy Cloud。",
"nextRefresh": "您的點數將於 {date} 重置。",
"outOfCredits": {
"subtitle": "訂閱以解鎖加值與更多功能",
"title": "您的免費點數已用完"
},
"subscribeCta": "訂閱以獲得更多",
"title": "您目前使用的是免費方案",
"topUpBlocked": {
"title": "解鎖加值與更多功能"
},
"upgradeCta": "查看方案"
},
"gpuLabel": "RTX 6000 Pro96GB VRAM",
"haveQuestions": "有疑問或想了解企業方案?",
"invoiceHistory": "發票記錄",
@@ -2777,7 +2732,6 @@
"maxDuration": {
"creator": "30 分鐘",
"founder": "30 分鐘",
"free": "30 分鐘",
"pro": "1 小時",
"standard": "30 分鐘"
},
@@ -2850,9 +2804,6 @@
"founder": {
"name": "創始版"
},
"free": {
"name": "免費"
},
"pro": {
"name": "專業版"
},

View File

@@ -225,7 +225,6 @@
"login": {
"andText": "和",
"backToLogin": "返回登录",
"backToSocialLogin": "改用 Google 或 Github 注册",
"confirmPasswordLabel": "确认密码",
"confirmPasswordPlaceholder": "再次输入相同的密码",
"didntReceiveEmail": "没有收到邮件?请联系我们:",
@@ -234,9 +233,6 @@
"failed": "登录失败",
"forgotPassword": "忘记密码?",
"forgotPasswordError": "发送重置密码邮件失败",
"freeTierBadge": "可享免费套餐",
"freeTierDescription": "使用 Google 注册,每月可获得 {credits} 免费积分。无需绑定银行卡。",
"freeTierDescriptionGeneric": "使用 Google 注册,每月可获得免费积分。无需绑定银行卡。",
"insecureContextWarning": "此连接不安全HTTP—如果继续登录您的凭据可能会被攻击者拦截。",
"loginButton": "登录",
"loginWithGithub": "使用Github登录",
@@ -255,13 +251,11 @@
"sendResetLink": "发送重置链接",
"signInOrSignUp": "登录 / 注册",
"signUp": "注册",
"signUpFreeTierPromo": "新用户?使用 Google {signUp},每月可获得 {credits} 免费积分。",
"success": "登录成功",
"termsLink": "使用条款",
"termsText": "点击“下一步”或“注册”即表示您同意我们的",
"title": "登录您的账户",
"useApiKey": "Comfy API 密钥",
"useEmailInstead": "改用邮箱",
"userAvatar": "用户头像"
},
"loginButton": {
@@ -288,7 +282,6 @@
"signup": {
"alreadyHaveAccount": "已经有账户了?",
"emailLabel": "电子邮件",
"emailNotEligibleForFreeTier": "邮箱注册不支持免费套餐。",
"emailPlaceholder": "输入您的电子邮件",
"passwordLabel": "密码",
"passwordPlaceholder": "输入新密码",
@@ -1338,20 +1331,6 @@
"switchToSelectButton": "切换到选择"
},
"beta": "App 模式测试版 - 提供反馈",
"builder": {
"exit": "退出构建器",
"exitConfirmMessage": "您有未保存的更改将会丢失\n确定不保存直接退出吗",
"exitConfirmTitle": "退出应用构建器?",
"inputsDesc": "用户可通过这些输入项进行交互和调整,以生成输出结果。",
"inputsExample": "示例:“加载图像”、“文本提示”、“步数”",
"noInputs": "尚未添加输入项",
"noOutputs": "尚未添加输出节点",
"outputsDesc": "请至少连接一个输出节点,用户运行后才能看到结果。",
"outputsExample": "示例:“保存图像”或“保存视频”",
"promptAddInputs": "点击节点参数,将其添加为输入项",
"promptAddOutputs": "点击输出节点,将其添加到此处。这些将作为生成结果。",
"title": "应用构建模式"
},
"downloadAll": "全部下载",
"dragAndDropImage": "拖拽图片到此处",
"graphMode": "图形模式",
@@ -1889,18 +1868,11 @@
"showLinks": "显示连接"
},
"missingModelsDialog": {
"customModelsInstruction": "您需要手动查找并下载这些模型。请在网上搜索(如 Civitai 或 Hugging Face或联系原始工作流提供者。",
"customModelsWarning": "其中一些是我们无法识别的自定义模型。",
"description": "此工作流需要您尚未下载的模型。",
"doNotAskAgain": "不再显示此消息",
"downloadAll": "全部下载",
"downloadAvailable": "下载可用项",
"footerDescription": "请下载并将这些模型放入正确的文件夹。\n画布上缺少模型的节点会以红色高亮显示。",
"gotIt": "好的,知道了",
"missingModels": "缺少模型",
"missingModelsMessage": "加载工作流时,未找到以下模型",
"reEnableInSettings": "可在{link}中重新启用",
"reEnableInSettingsLink": "设置",
"title": "此工作流缺少模型",
"totalSize": "总下载大小:"
"reEnableInSettingsLink": "设置"
},
"missingNodes": {
"cloud": {
@@ -2724,9 +2696,7 @@
"addCreditsLabel": "随时获取更多积分",
"benefits": {
"benefit1": "合作伙伴节点的月度积分 — 按需充值",
"benefit1FreeTier": "每月更多积分,随时补充",
"benefit2": "每个队列最长运行 30 分钟",
"benefit3": "支持自带模型Creator & Pro"
"benefit2": "每个队列最长运行 30 分钟"
},
"beta": "测试版",
"billedMonthly": "每月付款",
@@ -2764,21 +2734,6 @@
"description": "选择最适合您的订阅计划",
"descriptionWorkspace": "为您的工作区选择最佳方案",
"expiresDate": "于 {date} 过期",
"freeTier": {
"description": "您的免费套餐每月包含 {credits} 积分,可体验 Comfy Cloud。",
"descriptionGeneric": "您的免费套餐每月包含积分额度,可体验 Comfy Cloud。",
"nextRefresh": "您的积分将在 {date} 刷新。",
"outOfCredits": {
"subtitle": "订阅以解锁补充积分和更多功能",
"title": "您的免费积分已用完"
},
"subscribeCta": "订阅获取更多",
"title": "您正在使用免费套餐",
"topUpBlocked": {
"title": "解锁补充积分和更多功能"
},
"upgradeCta": "查看套餐"
},
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"haveQuestions": "对企业级有疑问?",
"invoiceHistory": "发票历史",
@@ -2789,7 +2744,6 @@
"maxDuration": {
"creator": "30 分钟",
"founder": "30 分钟",
"free": "30 分钟",
"pro": "1 小时",
"standard": "30 分钟"
},
@@ -2862,9 +2816,6 @@
"founder": {
"name": "Founder's Edition"
},
"free": {
"name": "免费"
},
"pro": {
"name": "Pro"
},

View File

@@ -35,29 +35,4 @@ describe('AssetsListItem', () => {
expect(wrapper.find('video').exists()).toBe(false)
expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(false)
})
it('emits preview-click when preview is clicked', async () => {
const wrapper = mount(AssetsListItem, {
props: {
previewUrl: 'https://example.com/preview.jpg',
previewAlt: 'image.png'
}
})
await wrapper.find('img').trigger('click')
expect(wrapper.emitted('preview-click')).toHaveLength(1)
})
it('emits preview-click when fallback icon is clicked', async () => {
const wrapper = mount(AssetsListItem, {
props: {
iconName: 'icon-[lucide--box]'
}
})
await wrapper.find('i').trigger('click')
expect(wrapper.emitted('preview-click')).toHaveLength(1)
})
})

View File

@@ -35,11 +35,7 @@
:icon-class="iconClass"
:icon-aria-label="iconAriaLabel"
>
<div
v-if="previewUrl"
class="relative size-full"
@click="emit('preview-click')"
>
<div v-if="previewUrl" class="relative size-full">
<template v-if="isVideoPreview">
<video
:src="previewUrl"
@@ -57,11 +53,7 @@
class="size-full object-cover"
/>
</div>
<div
v-else
class="flex size-full items-center justify-center"
@click="emit('preview-click')"
>
<div v-else class="flex size-full items-center justify-center">
<i
aria-hidden="true"
:class="
@@ -143,7 +135,6 @@ import VideoPlayOverlay from './VideoPlayOverlay.vue'
const emit = defineEmits<{
'stack-toggle': []
'preview-click': []
}>()
const {

View File

@@ -282,6 +282,7 @@ const getCheckoutTier = (
const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}

View File

@@ -44,17 +44,7 @@
<template #header />
<template #content>
<template v-if="activePanel">
<Suspense>
<component :is="activePanel.component" v-bind="activePanel.props" />
<template #fallback>
<div>
{{ $t('g.loadingPanel', { panel: activePanel.node.label }) }}
</div>
</template>
</Suspense>
</template>
<template v-else-if="inSearch">
<template v-if="inSearch">
<SettingsPanel :setting-groups="searchResults" />
</template>
<template v-else-if="activeSettingCategory">
@@ -64,6 +54,16 @@
/>
<SettingsPanel :setting-groups="sortedGroups(activeSettingCategory)" />
</template>
<template v-else-if="activePanel">
<Suspense>
<component :is="activePanel.component" v-bind="activePanel.props" />
<template #fallback>
<div>
{{ $t('g.loadingPanel', { panel: activePanel.node.label }) }}
</div>
</template>
</Suspense>
</template>
</template>
</BaseModalLayout>
</template>
@@ -110,7 +110,6 @@ const {
searchQuery,
inSearch,
searchResultsCategories,
matchedNavItemKeys,
handleSearch: handleSearchBase,
getSearchResults
} = useSettingSearch()
@@ -120,29 +119,16 @@ const authActions = useFirebaseAuthActions()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
const searchableNavItems = computed(() =>
navGroups.value.flatMap((g) =>
g.items.map((item) => ({
key: item.id,
label: item.label
}))
)
)
watch(
[searchResultsCategories, matchedNavItemKeys],
([categories, navKeys]) => {
if (!inSearch.value || (categories.size === 0 && navKeys.size === 0)) return
const firstMatch = navGroups.value
.flatMap((g) => g.items)
.find((item) => {
if (navKeys.has(item.id)) return true
const node = findCategoryByKey(item.id)
return node && categories.has(node.label)
})
activeCategoryKey.value = firstMatch?.id ?? null
}
)
watch(searchResultsCategories, (categories) => {
if (!inSearch.value || categories.size === 0) return
const firstMatch = navGroups.value
.flatMap((g) => g.items)
.find((item) => {
const node = findCategoryByKey(item.id)
return node && categories.has(node.label)
})
activeCategoryKey.value = firstMatch?.id ?? null
})
const activeSettingCategory = computed<SettingTreeNode | null>(() => {
if (!activeCategoryKey.value) return null
@@ -177,7 +163,7 @@ function sortedGroups(category: SettingTreeNode): ISettingGroup[] {
}
function handleSearch(query: string) {
handleSearchBase(query.trim(), searchableNavItems.value)
handleSearchBase(query.trim())
if (query) {
activeCategoryKey.value = null
} else if (!activeCategoryKey.value) {
@@ -189,7 +175,12 @@ function onNavItemClick(id: string) {
activeCategoryKey.value = id
}
const searchResults = computed<ISettingGroup[]>(() => getSearchResults(null))
const searchResults = computed<ISettingGroup[]>(() => {
const category = activeCategoryKey.value
? findCategoryByKey(activeCategoryKey.value)
: null
return getSearchResults(category)
})
// Scroll to and highlight the target setting once the correct category renders.
if (scrollToSettingId) {

View File

@@ -2,15 +2,6 @@
<div class="setting-group">
<Divider v-if="divider" />
<h3>
<span v-if="group.category" class="text-muted">
{{
$t(
`settingsCategories.${normalizeI18nKey(group.category)}`,
group.category
)
}}
&#8250;
</span>
{{
$t(`settingsCategories.${normalizeI18nKey(group.label)}`, group.label)
}}
@@ -36,7 +27,6 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{
group: {
label: string
category?: string
settings: SettingParams[]
}
divider?: boolean

View File

@@ -299,12 +299,10 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'Advanced',
category: 'Category',
settings: [mockSettings['Category.Setting2']]
}
])
@@ -334,50 +332,15 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'SubCategory',
category: 'Other',
settings: [mockSettings['Other.Setting3']]
}
])
})
it('returns results from all categories when searching cross-category term', () => {
// Simulates the "badge" scenario: same term matches settings in
// multiple categories (e.g. LiteGraph and Comfy)
mockSettings['LiteGraph.BadgeSetting'] = {
id: 'LiteGraph.BadgeSetting',
name: 'Node source badge mode',
type: 'combo',
defaultValue: 'default',
category: ['LiteGraph', 'Node']
}
mockSettings['Comfy.BadgeSetting'] = {
id: 'Comfy.BadgeSetting',
name: 'Show API node pricing badge',
type: 'boolean',
defaultValue: true,
category: ['Comfy', 'API Nodes']
}
const search = useSettingSearch()
search.handleSearch('badge')
expect(search.filteredSettingIds.value).toContain(
'LiteGraph.BadgeSetting'
)
expect(search.filteredSettingIds.value).toContain('Comfy.BadgeSetting')
// getSearchResults(null) should return both categories' results
const results = search.getSearchResults(null)
const labels = results.map((g) => g.label)
expect(labels).toContain('Node')
expect(labels).toContain('API Nodes')
})
it('returns empty array when no filtered results', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = []
@@ -409,7 +372,6 @@ describe('useSettingSearch', () => {
expect(results).toEqual([
{
label: 'Basic',
category: 'Category',
settings: [
mockSettings['Category.Setting1'],
mockSettings['Category.Setting4']
@@ -419,75 +381,6 @@ describe('useSettingSearch', () => {
})
})
describe('nav item matching', () => {
const navItems = [
{ key: 'keybinding', label: 'Keybinding' },
{ key: 'about', label: 'About' },
{ key: 'extension', label: 'Extension' },
{ key: 'Comfy', label: 'Comfy' }
]
it('matches nav items by key', () => {
const search = useSettingSearch()
search.handleSearch('keybinding', navItems)
expect(search.matchedNavItemKeys.value.has('keybinding')).toBe(true)
})
it('matches nav items by translated label (case insensitive)', () => {
const search = useSettingSearch()
search.handleSearch('ABOUT', navItems)
expect(search.matchedNavItemKeys.value.has('about')).toBe(true)
})
it('matches partial nav item labels', () => {
const search = useSettingSearch()
search.handleSearch('ext', navItems)
expect(search.matchedNavItemKeys.value.has('extension')).toBe(true)
})
it('clears matched nav item keys on empty query', () => {
const search = useSettingSearch()
search.handleSearch('keybinding', navItems)
expect(search.matchedNavItemKeys.value.size).toBeGreaterThan(0)
search.handleSearch('', navItems)
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
it('can match both settings and nav items simultaneously', () => {
const search = useSettingSearch()
search.handleSearch('other', navItems)
expect(search.filteredSettingIds.value).toContain('Other.Setting3')
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
it('matches nav items with translated labels different from key', () => {
const translatedNavItems = [{ key: 'keybinding', label: '키 바인딩' }]
const search = useSettingSearch()
search.handleSearch('키 바인딩', translatedNavItems)
expect(search.matchedNavItemKeys.value.has('keybinding')).toBe(true)
})
it('does not match nav items when no nav items provided', () => {
const search = useSettingSearch()
search.handleSearch('keybinding')
expect(search.matchedNavItemKeys.value.size).toBe(0)
})
})
describe('edge cases', () => {
it('handles empty settings store', () => {
mockSettingStore.settingsById = {}

View File

@@ -10,18 +10,12 @@ import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
interface SearchableNavItem {
key: string
label: string
}
export function useSettingSearch() {
const settingStore = useSettingStore()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
const matchedNavItemKeys = ref<Set<string>>(new Set())
const searchInProgress = ref<boolean>(false)
watch(searchQuery, () => (searchInProgress.value = true))
@@ -52,9 +46,7 @@ export function useSettingSearch() {
/**
* Handle search functionality
*/
const handleSearch = (query: string, navItems?: SearchableNavItem[]) => {
matchedNavItemKeys.value = new Set()
const handleSearch = (query: string) => {
if (!query) {
filteredSettingIds.value = []
return
@@ -97,17 +89,6 @@ export function useSettingSearch() {
)
})
if (navItems) {
for (const item of navItems) {
if (
item.key.toLocaleLowerCase().includes(queryLower) ||
item.label.toLocaleLowerCase().includes(queryLower)
) {
matchedNavItemKeys.value.add(item.key)
}
}
}
filteredSettingIds.value = filteredSettings.map((x) => x.id)
searchInProgress.value = false
}
@@ -118,42 +99,30 @@ export function useSettingSearch() {
const getSearchResults = (
activeCategory: SettingTreeNode | null
): ISettingGroup[] => {
const groupedSettings: {
[key: string]: { category: string; settings: SettingParams[] }
} = {}
const groupedSettings: { [key: string]: SettingParams[] } = {}
filteredSettingIds.value.forEach((id) => {
const setting = settingStore.settingsById[id]
const info = getSettingInfo(setting)
const groupKey =
activeCategory === null
? `${info.category}/${info.subCategory}`
: info.subCategory
const groupLabel = info.subCategory
if (activeCategory === null || activeCategory.label === info.category) {
if (!groupedSettings[groupKey]) {
groupedSettings[groupKey] = {
category: info.category,
settings: []
}
if (!groupedSettings[groupLabel]) {
groupedSettings[groupLabel] = []
}
groupedSettings[groupKey].settings.push(setting)
groupedSettings[groupLabel].push(setting)
}
})
return Object.entries(groupedSettings).map(
([key, { category, settings }]) => ({
label: activeCategory === null ? key.split('/')[1] : key,
...(activeCategory === null ? { category } : {}),
settings
})
)
return Object.entries(groupedSettings).map(([label, settings]) => ({
label,
settings
}))
}
return {
searchQuery,
filteredSettingIds,
matchedNavItemKeys,
searchInProgress,
searchResultsCategories,
queryIsEmpty,

View File

@@ -62,7 +62,6 @@ export interface FormItem {
export interface ISettingGroup {
label: string
category?: string
settings: SettingParams[]
}

View File

@@ -469,13 +469,18 @@ export const useWorkflowService = () => {
const { missingNodeTypes, missingModels } = wf.pendingWarnings
wf.pendingWarnings = null
if (missingNodeTypes?.length) {
// Remove modal once Node Replacement is implemented in TabErrors.
if (settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')) {
missingNodesDialog.show({ missingNodeTypes })
}
if (
missingNodeTypes?.length &&
settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')
) {
missingNodesDialog.show({ missingNodeTypes })
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
// For now, we'll make them coexist.
// Once the Node Replacement feature is implemented in TabErrors
// we'll remove the modal display and direct users to the error tab.
if (settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')) {
executionErrorStore.showErrorOverlay()
}
}
if (

View File

@@ -277,13 +277,7 @@ const zExtra = z
reroutes: z.array(zReroute).optional(),
workflowRendererVersion: zRendererType.optional(),
BlueprintDescription: z.string().optional(),
BlueprintSearchAliases: z.array(z.string()).optional(),
linearData: z
.object({
inputs: z.array(z.tuple([zNodeId, z.string()])).optional(),
outputs: z.array(zNodeId).optional()
})
.optional()
BlueprintSearchAliases: z.array(z.string()).optional()
})
.passthrough()

Some files were not shown because too many files have changed in this diff Show More