Compare commits

..

3 Commits

Author SHA1 Message Date
Jin Yi
dc731d50e5 fix: address review feedback for queue tab
- Disable clear-queue button based on pendingTasks count instead of activeJobsCount
- Use vi.hoisted() for test mock isolation in QueueAssetView tests
- Replace toReversed() with slice().reverse() for broader compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:56:48 +09:00
Jin Yi
a0a762898e fix: address review feedback for queue tab
- Replace queuedCount with activeJobsCount for clear button disabled state
- Convert clear queue label text to v-tooltip
- Simplify getJobIconClass logic
- Remove unnecessary comment and extra blank line
- Add QueueAssetView unit tests (FIFO ordering, state filtering)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:34:54 +09:00
Jin Yi
d0c58b79e3 feature: queue tab 2026-02-10 12:34:53 +09:00
139 changed files with 752 additions and 6694 deletions

View File

@@ -53,13 +53,7 @@ jobs:
IS_NIGHTLY: ${{ case(github.ref == 'refs/heads/main', 'true', 'false') }}
run: |
pnpm install --frozen-lockfile
# Desktop-specific release artifact with desktop distribution flags.
DISTRIBUTION=desktop pnpm build
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
NX_SKIP_NX_CACHE=true pnpm build
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6
@@ -68,7 +62,6 @@ jobs:
path: |
dist/
dist.zip
dist-desktop.zip
draft_release:
needs: build
@@ -86,7 +79,6 @@ jobs:
with:
files: |
dist.zip
dist-desktop.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: >-

View File

@@ -61,7 +61,8 @@
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
"cwd": "apps/desktop-ui",
"command": "vite build --config vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"

View File

@@ -1,205 +0,0 @@
{
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "T2IAdapterLoader",
"pos": [100, 100],
"size": [300, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CONTROL_NET",
"type": "CONTROL_NET",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "T2IAdapterLoader"
},
"widgets_values": ["t2iadapter_model.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [100, 300],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 3,
"type": "ResizeImagesByLongerEdge",
"pos": [500, 100],
"size": [300, 80],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ResizeImagesByLongerEdge"
},
"widgets_values": [1024]
},
{
"id": 4,
"type": "ImageScaleBy",
"pos": [500, 280],
"size": [300, 80],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2, 3],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageScaleBy"
},
"widgets_values": ["lanczos", 1.5]
},
{
"id": 5,
"type": "ImageBatch",
"pos": [900, 100],
"size": [300, 80],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 2
},
{
"name": "image2",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageBatch"
},
"widgets_values": []
},
{
"id": 6,
"type": "SaveImage",
"pos": [900, 300],
"size": [300, 80],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "PreviewImage",
"pos": [1250, 100],
"size": [300, 250],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 3, 0, 4, 0, "IMAGE"],
[2, 4, 0, 5, 0, "IMAGE"],
[3, 4, 0, 6, 0, "IMAGE"],
[4, 5, 0, 7, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,186 +0,0 @@
{
"last_node_id": 5,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "Load3DAnimation",
"pos": [100, 100],
"size": [300, 100],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MESH",
"type": "MESH",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Load3DAnimation"
},
"widgets_values": ["model.glb"]
},
{
"id": 2,
"type": "Preview3DAnimation",
"pos": [450, 100],
"size": [300, 100],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "mesh",
"type": "MESH",
"link": null
}
],
"properties": {
"Node name for S&R": "Preview3DAnimation"
},
"widgets_values": []
},
{
"id": 3,
"type": "ConditioningAverage ",
"pos": [100, 300],
"size": [300, 100],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "conditioning_to",
"type": "CONDITIONING",
"link": null
},
{
"name": "conditioning_from",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ConditioningAverage "
},
"widgets_values": [1]
},
{
"id": 4,
"type": "SDV_img2vid_Conditioning",
"pos": [450, 300],
"size": [300, 150],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip_vision",
"type": "CLIP_VISION",
"link": null
},
{
"name": "init_image",
"type": "IMAGE",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
},
{
"name": "negative",
"type": "CONDITIONING",
"links": [],
"slot_index": 1
},
{
"name": "latent",
"type": "LATENT",
"links": [2],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "SDV_img2vid_Conditioning"
},
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
},
{
"id": 5,
"type": "KSampler",
"pos": [800, 300],
"size": [300, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
}
],
"links": [
[1, 3, 0, 5, 1, "CONDITIONING"],
[2, 4, 2, 5, 3, "LATENT"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -298,10 +298,7 @@ test.describe('Settings', () => {
await input.press('Alt+n')
const requestPromise = comfyPage.page.waitForRequest(
(req) =>
req.url().includes('/api/settings') &&
!req.url().includes('/api/settings/') &&
req.method() === 'POST'
'**/api/settings/Comfy.Keybinding.NewBindings'
)
// Save keybinding

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

7
global.d.ts vendored
View File

@@ -5,11 +5,6 @@ declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
interface ImpactQueueFunction {
(...args: unknown[]): void
a?: unknown[][]
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
@@ -42,8 +37,6 @@ interface Window {
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
ire_o?: string
ire?: ImpactQueueFunction
}
interface Navigator {

View File

@@ -35,6 +35,18 @@
background-size: cover;
background-repeat: no-repeat;
}
#vue-app:has(#loading-logo) {
display: contents;
color: var(--fg-color);
& #loading-logo {
place-self: center;
font-size: clamp(2px, 1vw, 6px);
line-height: 1;
overflow: hidden;
max-width: 100vw;
border-radius: 20ch;
}
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -53,6 +65,36 @@
<body class="litegraph grid">
<div id="vue-app">
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
<svg
width="520"
height="520"
viewBox="0 0 520 520"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="loading-logo"
>
<mask
id="mask0_227_285"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="520"
height="520"
>
<path
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
fill="#EEFF30"
/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
<path
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
fill="#F0FF41"
/>
</g>
</svg>
</div>
<script type="module" src="src/main.ts"></script>
</body>

View File

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

View File

@@ -1,14 +1,9 @@
import zipdir from 'zip-dir'
const sourceDir = process.argv[2] || './dist'
const outputPath = process.argv[3] || './dist.zip'
zipdir(sourceDir, { saveTo: outputPath }, function (err, buffer) {
zipdir('./dist', { saveTo: './dist.zip' }, function (err, buffer) {
if (err) {
console.error(`Error zipping "${sourceDir}" directory:`, err)
console.error('Error zipping "dist" directory:', err)
} else {
process.stdout.write(
`Successfully zipped "${sourceDir}" directory to "${outputPath}".\n`
)
console.log('Successfully zipped "dist" directory.')
}
})

View File

@@ -1,9 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadFile,
extractFilenameFromContentDisposition
} from '@/base/common/downloadUtil'
import { downloadFile } from '@/base/common/downloadUtil'
let mockIsCloud = false
@@ -158,14 +155,10 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
blob: blobFn
} as unknown as Response)
downloadFile(testUrl)
@@ -202,147 +195,5 @@ describe('downloadUtil', () => {
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
expect(mockLink.download).toBe('user-friendly.png')
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi
.fn()
.mockReturnValue(
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('中文.png')
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl, 'my-fallback.png')
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('my-fallback.png')
})
})
describe('extractFilenameFromContentDisposition', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()
})
it('returns null for empty header', () => {
expect(extractFilenameFromContentDisposition('')).toBeNull()
})
it('extracts filename from simple quoted format', () => {
const header = 'attachment; filename="test-file.png"'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from unquoted format', () => {
const header = 'attachment; filename=test-file.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from RFC 5987 format', () => {
const header = "attachment; filename*=UTF-8''test%20file.png"
expect(extractFilenameFromContentDisposition(header)).toBe(
'test file.png'
)
})
it('prefers RFC 5987 format over simple format', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'preferred.png'
)
})
it('handles unicode characters in RFC 5987 format', () => {
const header =
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
})
it('falls back to simple format when RFC 5987 decoding fails', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
})
it('handles header with only attachment disposition', () => {
const header = 'attachment'
expect(extractFilenameFromContentDisposition(header)).toBeNull()
})
it('handles case-insensitive filename parameter', () => {
const header = 'attachment; FILENAME="test.png"'
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
})
})
})

View File

@@ -75,57 +75,14 @@ const extractFilenameFromUrl = (url: string): string | null => {
}
}
/**
* Extract filename from Content-Disposition header
* Handles both simple format: attachment; filename="name.png"
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
* @param header - The Content-Disposition header value
* @returns The extracted filename or null if not found
*/
export function extractFilenameFromContentDisposition(
header: string | null
): string | null {
if (!header) return null
// Try RFC 5987 extended format first (filename*=UTF-8''...)
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
if (extendedMatch?.[1]) {
try {
return decodeURIComponent(extendedMatch[1])
} catch {
// Fall through to simple format
}
}
// Try simple quoted format: filename="..."
const quotedMatch = header.match(/filename="([^"]+)"/i)
if (quotedMatch?.[1]) {
return quotedMatch[1]
}
// Try unquoted format: filename=...
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
if (unquotedMatch?.[1]) {
return unquotedMatch[1]
}
return null
}
const downloadViaBlobFetch = async (
href: string,
fallbackFilename: string
filename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
const headerFilename =
extractFilenameFromContentDisposition(contentDisposition)
const blob = await response.blob()
downloadBlob(headerFilename ?? fallbackFilename, blob)
downloadBlob(filename, blob)
}

View File

@@ -1,8 +1,5 @@
<template>
<div
ref="container"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div ref="container" class="h-full scrollbar-custom">
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div

View File

@@ -18,35 +18,17 @@
<div class="flex justify-end gap-4">
<div
v-if="type === 'overwriteBlueprint'"
class="flex flex-col justify-start gap-1"
class="flex justify-start gap-4"
>
<div class="flex gap-4">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-8"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openBlueprintOverwriteSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
<Checkbox
v-model="doNotAskAgain"
class="flex justify-start gap-4"
input-id="doNotAskAgain"
binary
/>
<label for="doNotAskAgain" severity="secondary">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<Button
@@ -110,13 +92,13 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -132,14 +114,6 @@ const { t } = useI18n()
const onCancel = () => useDialogStore().closeDialog()
function openBlueprintOverwriteSetting() {
useDialogStore().closeDialog()
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.WarnBlueprintOverwrite'
)
}
const doNotAskAgain = ref(false)
const onDeny = () => {

View File

@@ -5,34 +5,11 @@
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
/>
<div class="mb-4 flex flex-col gap-1">
<div class="flex gap-1">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
<div class="mb-4 flex gap-1">
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
@@ -54,18 +31,16 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { isDesktop } from '@/platform/distribution/types'
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
@@ -103,14 +78,6 @@ const { t } = useI18n()
const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingModelsWarning'
)
}
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {

View File

@@ -1,12 +1,12 @@
<template>
<div
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
:class="isCloud ? 'border-b' : ''"
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->
<div>
<p class="m-0 text-sm leading-5 text-muted-foreground">
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.description')
@@ -14,210 +14,32 @@
}}
</p>
</div>
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
<!-- QUICK FIX AVAILABLE Section -->
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
<!-- Section header with Replace button -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-primary">
{{ $t('nodeReplacement.quickFixAvailable') }}
</span>
<div class="h-2 w-2 rounded-full bg-primary" />
</div>
<Button
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
variant="primary"
size="md"
:disabled="selectedTypes.size === 0"
@click="handleReplaceSelected"
>
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
{{
$t('nodeReplacement.replaceSelected', {
count: selectedTypes.size
})
}}
</Button>
</div>
<!-- Replaceable nodes list -->
<div
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<!-- Select All row (sticky header) -->
<div
:class="
cn(
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
pendingNodes.length > 0
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
"
@click="toggleSelectAll"
@keydown.enter.prevent="toggleSelectAll"
@keydown.space.prevent="toggleSelectAll"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
isAllSelected || isSomeSelected
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="isAllSelected"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
<i
v-else-if="isSomeSelected"
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
/>
</div>
<span class="text-xs font-medium uppercase text-muted-foreground">
{{ $t('nodeReplacement.compatibleAlternatives') }}
</span>
</div>
<!-- Replaceable node items -->
<div
v-for="node in replaceableNodes"
:key="node.label"
:class="
cn(
'flex items-center gap-3 px-3 py-2',
replacedTypes.has(node.label)
? 'opacity-50 pointer-events-none'
: 'cursor-pointer hover:bg-secondary-background-hover'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'true'
: 'false'
"
@click="toggleNode(node.label)"
@keydown.enter.prevent="toggleNode(node.label)"
@keydown.space.prevent="toggleNode(node.label)"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
v-if="replacedTypes.has(node.label)"
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
>
{{ $t('nodeReplacement.replaced') }}
</span>
<span
v-else
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
>
{{ $t('nodeReplacement.replaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
</span>
</div>
</div>
</div>
</div>
<!-- MANUAL INSTALLATION REQUIRED Section -->
<!-- Missing Nodes List Wrapper -->
<div
v-if="nonReplaceableNodes.length > 0"
class="flex max-h-[200px] flex-col gap-2"
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
>
<!-- Section header -->
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-error">
{{ $t('nodeReplacement.installationRequired') }}
<div
v-for="(node, i) in uniqueNodes"
:key="i"
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
>
<span class="text-xs">
{{ node.label }}
</span>
<i class="icon-[lucide--info] text-xs text-error" />
</div>
<!-- Non-replaceable nodes list -->
<div
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="node in nonReplaceableNodes"
:key="node.label"
class="flex items-center justify-between px-4 py-3"
>
<div class="flex items-center gap-3">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
>
{{ $t('nodeReplacement.notReplaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span v-if="node.hint" class="text-xs text-muted-foreground">
{{ node.hint }}
</span>
</div>
</div>
<Button
v-if="node.action"
variant="destructive-textonly"
size="sm"
@click="node.action.callback"
>
{{ node.action.text }}
</Button>
</div>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
</div>
</div>
<!-- Bottom instruction box -->
<div
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<p class="m-0 text-xs leading-5 text-neutral-foreground">
<i18n-t keypath="nodeReplacement.instructionMessage">
<template #red>
<span class="text-error">{{
$t('nodeReplacement.redHighlight')
}}</span>
</template>
</i18n-t>
<!-- Bottom instruction -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.replacementInstruction')
: $t('missingNodes.oss.replacementInstruction')
}}
</p>
</div>
</div>
@@ -225,39 +47,23 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const { missingNodeTypes } = defineProps<{
const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
interface ProcessedNode {
label: string
hint?: string
action?: { text: string; callback: () => void }
isReplaceable: boolean
replacement?: NodeReplacement
}
const replacedTypes = ref<Set<string>>(new Set())
const uniqueNodes = computed<ProcessedNode[]>(() => {
const seenTypes = new Set<string>()
return missingNodeTypes
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
if (seenTypes.has(type)) return false
@@ -269,81 +75,10 @@ const uniqueNodes = computed<ProcessedNode[]>(() => {
return {
label: node.type,
hint: node.hint,
action: node.action,
isReplaceable: node.isReplaceable ?? false,
replacement: node.replacement
action: node.action
}
}
return { label: node, isReplaceable: false }
return { label: node }
})
})
const replaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => n.isReplaceable)
)
const pendingNodes = computed(() =>
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
)
const nonReplaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => !n.isReplaceable)
)
// Selection state - all pending nodes selected by default
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
const isAllSelected = computed(
() =>
pendingNodes.value.length > 0 &&
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
)
const isSomeSelected = computed(
() => selectedTypes.value.size > 0 && !isAllSelected.value
)
function toggleNode(label: string) {
if (replacedTypes.value.has(label)) return
const next = new Set(selectedTypes.value)
if (next.has(label)) {
next.delete(label)
} else {
next.add(label)
}
selectedTypes.value = next
}
function toggleSelectAll() {
if (isAllSelected.value) {
selectedTypes.value = new Set()
} else {
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
}
}
function handleReplaceSelected() {
const selected = missingNodeTypes.filter((node) => {
const type = typeof node === 'object' ? node.type : node
return selectedTypes.value.has(type)
})
const result = replaceNodesInPlace(selected)
const nextReplaced = new Set(replacedTypes.value)
const nextSelected = new Set(selectedTypes.value)
for (const type of result) {
nextReplaced.add(type)
nextSelected.delete(type)
}
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)
)
if (allReplaced && nonReplaceableNodes.value.length === 0) {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
}
</script>

View File

@@ -1,138 +1,72 @@
<template>
<div class="flex w-full flex-col gap-2 py-2 px-4">
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
<div class="flex items-center gap-1">
<input
id="doNotAskAgainNodes"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgainNodes">{{
$t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingNodesSetting"
>
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleGotItClick">
{{ $t('nodeReplacement.skipForNow') }}
</Button>
</div>
<!-- Cloud mode: Learn More + Got It buttons -->
<div
v-else-if="isCloud"
class="flex w-full items-center justify-between gap-2"
<!-- Cloud mode: Learn More + Got It buttons -->
<div
v-if="isCloud"
class="flex w-full items-center justify-between gap-2 py-2 px-4"
>
<Button
variant="textonly"
size="sm"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="textonly"
size="sm"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<i class="icon-[lucide--info]"></i>
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
</Button>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>
<i class="icon-[lucide--info]"></i>
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
</Button>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>
<!-- OSS mode: Manager buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
<Button variant="textonly" @click="handleOpenManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
size="md"
:disabled="
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
"
:is-loading="isLoading"
:node-packs="missingNodePacks"
:label="
isLoading
? $t('manager.gettingInfo')
: $t('manager.installAllMissingNodes')
"
/>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
size="md"
:disabled="
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
"
:is-loading="isLoading"
:node-packs="missingNodePacks"
:label="
isLoading
? $t('manager.gettingInfo')
: $t('manager.installAllMissingNodes')
"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { missingNodeTypes } = defineProps<{
missingNodeTypes?: MissingNodeType[]
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
const doNotAskAgain = ref(false)
watch(doNotAskAgain, (value) => {
void useSettingStore().set('Comfy.Workflow.ShowMissingNodesWarning', !value)
})
const handleGotItClick = () => {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
function openShowMissingNodesSetting() {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingNodesWarning'
)
}
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
function handleOpenManager() {
managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Check if any of the missing packs are currently being installed
const isInstalling = computed(() => {
@@ -152,29 +86,15 @@ const showInstallAllButton = computed(() => {
return managerState.shouldShowInstallButton.value
})
const hasNonReplaceableNodes = computed(
() =>
missingNodeTypes?.some(
(n) =>
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
) ?? false
)
const openManager = async () => {
await managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
const hadMissingPacks = ref(false)
watch(
missingNodePacks,
(packs) => {
if (packs && packs.length > 0) hadMissingPacks.value = true
},
{ immediate: true }
)
// Only consider "all installed" when packs transitioned from non-empty to empty
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
// Computed to check if all missing nodes have been installed
const allMissingNodesInstalled = computed(() => {
if (!hadMissingPacks.value) return false
return (
!isLoading.value &&
!isInstalling.value &&

View File

@@ -94,7 +94,6 @@
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<NodeContextMenu />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
@@ -122,7 +121,6 @@ import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'

View File

@@ -42,6 +42,7 @@
</Panel>
</Transition>
</div>
<NodeContextMenu />
</template>
<script setup lang="ts">
@@ -68,6 +69,7 @@ import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import NodeContextMenu from './NodeContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'

View File

@@ -1,23 +1,7 @@
<template>
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveMediaAssetCard
v-for="job in activeJobItems"
:key="job.id"
:job="job"
/>
</div>
<!-- Assets Header -->
<div
v-if="assets.length"
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
>
<div v-if="assets.length" class="px-2 2xl:px-4">
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
@@ -59,25 +43,18 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
isSelected,
isInFolderView = false,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
isInFolderView?: boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
@@ -92,19 +69,9 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const assetItems = computed<AssetGridItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
@@ -10,51 +9,12 @@ vi.mock('vue-i18n', () => ({
})
}))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
canCancelJob: ref(false),
runCancelJob: vi.fn()
})
}))
const mockJobItems = ref<
Array<{
id: string
title: string
meta: string
state: string
createTime?: number
}>
>([])
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: mockJobItems
})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
isAssetDeleting: () => false
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => key === 'Comfy.Queue.QPOV2'
})
}))
vi.mock('@/utils/queueUtil', () => ({
isActiveJobState: (state: string) =>
state === 'pending' || state === 'running'
}))
vi.mock('@/utils/queueDisplay', () => ({
iconForJobState: () => 'pi pi-spinner'
}))
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: () => undefined
}))
@@ -73,7 +33,6 @@ vi.mock('@/utils/formatUtil', () => ({
describe('AssetsSidebarListView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockJobItems.value = []
})
const defaultProps = {
@@ -84,67 +43,14 @@ describe('AssetsSidebarListView', () => {
toggleStack: async () => {}
}
it('displays active jobs in oldest-first order (FIFO)', () => {
mockJobItems.value = [
{
id: 'newest',
title: 'Newest Job',
meta: '',
state: 'pending',
createTime: 3000
},
{
id: 'middle',
title: 'Middle Job',
meta: '',
state: 'running',
createTime: 2000
},
{
id: 'oldest',
title: 'Oldest Job',
meta: '',
state: 'pending',
createTime: 1000
}
]
it('renders without errors with empty assets', () => {
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(3)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
})
it('excludes completed and failed jobs from active jobs section', () => {
mockJobItems.value = [
{ id: 'pending', title: 'Pending', meta: '', state: 'pending' },
{ id: 'completed', title: 'Completed', meta: '', state: 'completed' },
{ id: 'failed', title: 'Failed', meta: '', state: 'failed' },
{ id: 'running', title: 'Running', meta: '', state: 'running' }
]
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(2)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toContain('Running')
expect(displayedTitles).toContain('Pending')
expect(displayedTitles).not.toContain('Completed')
expect(displayedTitles).not.toContain('Failed')
expect(wrapper.exists()).toBe(true)
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(listItems).toHaveLength(0)
})
})

View File

@@ -1,48 +1,6 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<div
v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div v-if="assetItems.length" class="px-2">
<div
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
@@ -119,31 +77,25 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import { isActiveJobState } from '@/utils/queueUtil'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assetItems,
@@ -170,24 +122,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const listGridStyle = {
display: 'grid',
@@ -240,16 +175,6 @@ function getAssetCardClass(selected: boolean): string {
)
}
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}
@@ -259,13 +184,4 @@ function onAssetLeave(assetId: string) {
hoveredAssetId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script>

View File

@@ -26,6 +26,15 @@
<template #tool-buttons>
<!-- Normal Tab View -->
<TabList v-if="!isInFolderView" v-model="activeTab">
<Tab v-if="isQueuePanelV2Enabled" class="font-inter" value="queue">
{{ $t('sideToolbar.labels.queue') }}
<span
v-if="activeJobsCount > 0"
class="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-xs text-base-foreground font-medium h-5"
>
{{ activeJobsCount }}
</span>
</Tab>
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
@@ -43,8 +52,9 @@
</Button>
</div>
<!-- Filter Bar -->
<!-- Filter Bar (hidden on queue tab) -->
<MediaAssetFilterBar
v-if="!isQueueTab"
v-model:search-query="searchQuery"
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
@@ -53,30 +63,29 @@
:show-generation-time-sort="activeTab === 'output'"
/>
<div
v-if="isQueuePanelV2Enabled && !isInFolderView"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
v-if="isQueueTab && !isInFolderView"
class="flex items-center justify-between px-4 2xl:px-6"
>
<span class="text-sm text-muted-foreground">
{{ activeJobsLabel }}
</span>
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<MediaAssetViewModeToggle v-model:view-mode="viewMode" />
<Button
v-tooltip.top="clearQueueTooltip"
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
:disabled="queuedCount === 0"
:disabled="queueStore.pendingTasks.length === 0"
@click="handleClearQueue"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<Divider v-else type="dashed" class="my-2" />
<Divider v-else-if="!isQueueTab" type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="showLoadingState">
@@ -87,23 +96,32 @@
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
isQueueTab
? 'sideToolbar.noQueueItems'
: activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="
$t(
isQueueTab
? 'sideToolbar.noQueueItemsMessage'
: 'sideToolbar.noFilesFoundMessage'
)
"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<QueueAssetView v-if="isQueueTab" :view-mode="viewMode" />
<AssetsSidebarListView
v-if="isListView"
v-else-if="isListView"
:asset-items="listViewAssetItems"
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
:asset-type="assetTabType"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@@ -112,8 +130,7 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:is-in-folder-view="isInFolderView"
:asset-type="activeTab"
:asset-type="assetTabType"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@@ -224,13 +241,16 @@ const Load3dViewerContent = () =>
import('@/components/load3d/Load3dViewerContent.vue')
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import QueueAssetView from '@/components/sidebar/tabs/QueueAssetView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaAssetViewModeToggle from '@/platform/assets/components/MediaAssetViewModeToggle.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
@@ -257,7 +277,7 @@ const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const activeTab = ref<'input' | 'output' | 'queue'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
@@ -268,6 +288,10 @@ const viewMode = useStorage<'list' | 'grid'>(
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isQueueTab = computed(() => activeTab.value === 'queue')
const assetTabType = computed<'input' | 'output'>(() =>
activeTab.value === 'input' ? 'input' : 'output'
)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
@@ -302,7 +326,9 @@ const formattedExecutionTime = computed(() => {
return formatDuration(folderExecutionTime.value * 1000)
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const clearQueueTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -415,18 +441,15 @@ const isBulkMode = computed(
)
const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
() => !isQueueTab.value && loading.value && displayAssets.value.length === 0
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
const showEmptyState = computed(() => {
if (isQueueTab.value) {
return activeJobsCount.value === 0
}
return !loading.value && displayAssets.value.length === 0
})
watch(visibleAssets, (newAssets) => {
// Alternative: keep hidden selections and surface them in UI; for now prune
@@ -483,12 +506,21 @@ watch(
clearSelection()
// Clear search when switching tabs
searchQuery.value = ''
// Reset pagination state when tab changes
void refreshAssets()
// Skip asset fetch for queue tab
if (activeTab.value !== 'queue') {
void refreshAssets()
}
},
{ immediate: true }
)
// Reset to output tab if QPOV2 is disabled while on queue tab
watch(isQueuePanelV2Enabled, (enabled) => {
if (!enabled && activeTab.value === 'queue') {
activeTab.value = 'output'
}
})
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
const assetList = assets ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)

View File

@@ -0,0 +1,98 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import QueueAssetView from './QueueAssetView.vue'
const { mockJobItems } = vi.hoisted<{
mockJobItems: Ref<JobListItem[]>
}>(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref: hoistedRef } = require('vue')
return { mockJobItems: hoistedRef([]) }
})
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: mockJobItems
})
}))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
canCancelJob: ref(false),
runCancelJob: vi.fn()
})
}))
vi.mock('@/utils/queueUtil', () => ({
isActiveJobState: (state: string) =>
state === 'pending' || state === 'running' || state === 'initialization'
}))
vi.mock('@/utils/queueDisplay', () => ({
iconForJobState: () => 'pi pi-spinner'
}))
function makeJob(overrides: Partial<JobListItem>): JobListItem {
return {
id: 'job-1',
title: 'Job 1',
meta: '',
state: 'pending',
...overrides
}
}
describe('QueueAssetView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockJobItems.value = []
})
it('displays active jobs in oldest-first order (FIFO)', () => {
mockJobItems.value = [
makeJob({ id: 'newest', title: 'Newest Job', state: 'pending' }),
makeJob({ id: 'middle', title: 'Middle Job', state: 'running' }),
makeJob({ id: 'oldest', title: 'Oldest Job', state: 'pending' })
]
const wrapper = mount(QueueAssetView, {
props: { viewMode: 'list' },
shallow: true
})
const items = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(items).toHaveLength(3)
const titles = items.map((item) => item.props('primaryText'))
expect(titles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
})
it('excludes completed and failed jobs', () => {
mockJobItems.value = [
makeJob({ id: 'pending', title: 'Pending', state: 'pending' }),
makeJob({ id: 'completed', title: 'Completed', state: 'completed' }),
makeJob({ id: 'failed', title: 'Failed', state: 'failed' }),
makeJob({ id: 'running', title: 'Running', state: 'running' })
]
const wrapper = mount(QueueAssetView, {
props: { viewMode: 'list' },
shallow: true
})
const items = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(items).toHaveLength(2)
const titles = items.map((item) => item.props('primaryText'))
expect(titles).toContain('Running')
expect(titles).toContain('Pending')
expect(titles).not.toContain('Completed')
expect(titles).not.toContain('Failed')
})
})

View File

@@ -0,0 +1,124 @@
<template>
<div class="flex h-full flex-col">
<!-- Grid View -->
<VirtualGrid
v-if="viewMode === 'grid'"
class="flex-1"
:items="gridItems"
:grid-style="gridStyle"
>
<template #item="{ item }">
<ActiveMediaAssetCard :job="item.job" />
</template>
</VirtualGrid>
<!-- List View -->
<div
v-else
class="flex flex-1 scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { isActiveJobState } from '@/utils/queueUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const { viewMode = 'grid' } = defineProps<{
viewMode?: 'list' | 'grid'
}>()
const { jobItems } = useJobList()
const activeJobItems = computed(() =>
jobItems.value
.filter((item) => isActiveJobState(item.state))
.slice()
.reverse()
)
const gridItems = computed(() =>
activeJobItems.value.map((job) => ({
key: `queue-${job.id}`,
job
}))
)
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}
const hoveredJobId = ref<string | null>(null)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
return 'animate-spin'
}
return undefined
}
</script>

View File

@@ -36,7 +36,7 @@ defineProps<{
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot>
<div class="flex flex-col p-1">

View File

@@ -197,7 +197,6 @@ describe('useCoreCommands', () => {
addSetting: vi.fn(),
load: vi.fn(),
set: vi.fn(),
setMany: vi.fn(),
exists: vi.fn(),
getDefaultValue: vi.fn(),
isReady: true,

View File

@@ -20,8 +20,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements'
USER_SECRETS_ENABLED = 'user_secrets_enabled'
}
/**
@@ -97,9 +96,6 @@ export function useFeatureFlags() {
remoteConfig.value.user_secrets_enabled ??
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
)
},
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
}
})

View File

@@ -155,18 +155,11 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const getInputImageUrl = (): string | null => {
if (!node.value) return null
let sourceNode = node.value.getInputNode(0)
if (!sourceNode) return null
const inputNode = node.value.getInputNode(0)
if (sourceNode.isSubgraphNode()) {
const link = node.value.getInputLink(0)
if (!link) return null
const resolved = sourceNode.resolveSubgraphOutputLink(link.origin_slot)
sourceNode = resolved?.outputNode ?? null
if (!sourceNode) return null
}
if (!inputNode) return null
const urls = nodeOutputStore.getNodeImageUrls(sourceNode)
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
if (urls?.length) {
return urls[0]
@@ -569,10 +562,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
node.value = app.rootGraph?.getNodeById(nodeId) || null
}
updateImageUrl()

View File

@@ -219,15 +219,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
return modelPath
}
const trimmed = modelPath.trim()
const hasOutputSuffix = trimmed.endsWith('[output]')
const cleanPath = hasOutputSuffix
? trimmed.replace(/\s*\[output\]$/, '')
: trimmed
const type = hasOutputSuffix || isPreview.value ? 'output' : 'input'
const [subfolder, filename] = Load3dUtils.splitFilePath(cleanPath)
return api.apiURL(Load3dUtils.getResourceURL(subfolder, filename, type))
const [subfolder, filename] = Load3dUtils.splitFilePath(modelPath)
return api.apiURL(
Load3dUtils.getResourceURL(
subfolder,
filename,
isPreview.value ? 'output' : 'input'
)
)
} catch (error) {
console.error('Failed to construct model URL:', error)
return null

View File

@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/constants'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { createMockFileList } from '@/utils/__tests__/litegraphTestUtils'
@@ -200,7 +199,7 @@ describe('useLoad3dDrag', () => {
onModelDrop: mockOnModelDrop
})
const extensions = [...SUPPORTED_EXTENSIONS]
const extensions = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
for (const ext of extensions) {
vi.mocked(mockOnModelDrop).mockClear()

View File

@@ -3,8 +3,8 @@ import { describe, expect, test, vi } from 'vitest'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
@@ -119,94 +119,6 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.widgets[0].computedHeight = 10
expect(subgraphNode.widgets[0].value).toBe('value')
})
test('Proxy widget label shows widgetName, not "nodeId: widgetName"', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
expect(proxyWidget.name).toBe('1: seed')
})
test('Proxy widget label reflects linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
expect(proxyWidget.label).toBe('seed')
innerNodes[0].widgets![0].label = 'My Inner Label'
// Trigger re-resolve of linked widget
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Inner Label')
})
test('Proxy widget user rename takes priority over linked widget label', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'My Custom Seed'
expect(proxyWidget.label).toBe('My Custom Seed')
innerNodes[0].widgets![0].label = 'Inner Override'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('My Custom Seed')
})
test('Proxy widget label resets to linked widget on undefined', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'seed', 'value', () => {})
subgraphNode.properties.proxyWidgets = [['1', 'seed']]
const proxyWidget = subgraphNode.widgets[0]
proxyWidget.label = 'Custom'
expect(proxyWidget.label).toBe('Custom')
proxyWidget.label = undefined
innerNodes[0].widgets![0].label = 'Inner Label'
proxyWidget.computedHeight = 10
expect(proxyWidget.label).toBe('Inner Label')
})
test('Proxy widget labels are correct when loaded from serialized data', () => {
// Intentionally constructs SubgraphNode via constructor (not setupSubgraph)
// to exercise the deserialization/onConfigure path from blueprint JSON.
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
subgraph.add(innerNode)
innerNode.addWidget('text', 'seed', 'value', () => {})
innerNode.addWidget('text', 'steps', 'value', () => {})
const parentGraph = new LGraph()
const subgraphNode = new SubgraphNode(parentGraph, subgraph, {
id: 1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
properties: {
proxyWidgets: [
['1', 'seed'],
['1', 'steps']
]
},
flags: {},
mode: 0,
order: 0
})
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].label).toBe('seed')
expect(subgraphNode.widgets[0].name).toBe('1: seed')
expect(subgraphNode.widgets[1].label).toBe('steps')
expect(subgraphNode.widgets[1].name).toBe('1: steps')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})

View File

@@ -199,15 +199,12 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
* and the value used as 'this' if property is a get/set method
* @param {unknown} value - only used on set calls. The thing being assigned
*/
let userLabel: string | undefined
const handler = {
get(_t: IBaseWidget, property: string, receiver: object) {
let redirectedTarget: object = backingWidget
let redirectedReceiver = receiver
if (property == '_overlay') return overlay
else if (property == 'value') redirectedReceiver = backingWidget
else if (property == 'label')
return userLabel ?? linkedWidget?.label ?? overlay.widgetName
if (Object.prototype.hasOwnProperty.call(overlay, property)) {
redirectedTarget = overlay
redirectedReceiver = overlay
@@ -215,10 +212,6 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
return Reflect.get(redirectedTarget, property, redirectedReceiver)
},
set(_t: IBaseWidget, property: string, value: unknown) {
if (property == 'label') {
userLabel = value as string | undefined
return true
}
let redirectedTarget: object = backingWidget
if (property == 'computedHeight') {
if (overlay.widgetName.startsWith('$$') && linkedNode) {

View File

@@ -6,7 +6,6 @@ useExtensionService().registerExtension({
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'ImageCropV2') return
node.hideOutputImages = true
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)])
}

View File

@@ -9,7 +9,6 @@ import type {
CameraState
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -259,7 +258,10 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
const fileInput = createFileInput(
'.gltf,.glb,.obj,.fbx,.stl,.ply,.spz,.splat,.ksplat',
false
)
node.properties['Resource Folder'] = ''

View File

@@ -14,5 +14,3 @@ export const SUPPORTED_EXTENSIONS = new Set([
'.ply',
'.ksplat'
])
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')

View File

@@ -56,16 +56,6 @@ useExtensionService().registerExtension({
async beforeRegisterNodeDef(nodeType, nodeData) {
if (isLoad3dNodeType(nodeData.name)) {
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
// Load3D's model_file as a mesh upload widget without hardcoding.
if (nodeData.name === 'Load3D') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
// since invokeExtensionsAsync already captured the extensions snapshot
// before these new extensions were registered.

View File

@@ -52,12 +52,14 @@ useExtensionService().registerExtension({
showValueWidget.options.hidden = true
showValueWidget.options.read_only = true
showValueWidget.element.readOnly = true
showValueWidget.element.disabled = true
showValueWidget.serialize = false
showValueWidgetPlain.hidden = false
showValueWidgetPlain.options.hidden = false
showValueWidgetPlain.options.read_only = true
showValueWidgetPlain.element.readOnly = true
showValueWidgetPlain.element.disabled = true
showValueWidgetPlain.serialize = false
}

View File

@@ -70,7 +70,6 @@ async function uploadFile(
api.apiURL(getResourceURL(...splitFilePath(path)))
)
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
}

View File

@@ -1,5 +1,3 @@
import DOMPurify from 'dompurify'
import type {
ContextMenuDivElement,
IContextMenuOptions,
@@ -7,38 +5,6 @@ import type {
} from './interfaces'
import { LiteGraph } from './litegraph'
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
const ALLOWED_STYLE_PROPS = new Set([
'display',
'color',
'background-color',
'padding-left',
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
.map((s) => s.trim())
.filter((s) => {
const colonIdx = s.indexOf(':')
if (colonIdx === -1) return false
const prop = s.slice(0, colonIdx).trim().toLowerCase()
return ALLOWED_STYLE_PROPS.has(prop)
})
.join('; ')
data.attrValue = sanitizedStyle
}
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})
}
// TODO: Replace this pattern with something more modern.
export interface ContextMenu<TValue = unknown> {
constructor: new (
@@ -157,7 +123,7 @@ export class ContextMenu<TValue = unknown> {
if (options.title) {
const element = document.createElement('div')
element.className = 'litemenu-title'
element.textContent = options.title
element.innerHTML = options.title
root.append(element)
}
@@ -252,18 +218,11 @@ export class ContextMenu<TValue = unknown> {
if (value === null) {
element.classList.add('separator')
} else {
const label = name === null ? '' : String(name)
const innerHtml = name === null ? '' : String(name)
if (typeof value === 'string') {
element.textContent = label
element.innerHTML = innerHtml
} else {
// Use innerHTML for content that contains HTML tags, textContent otherwise
const hasHtmlContent =
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
if (hasHtmlContent) {
element.innerHTML = sanitizeMenuHTML(value.content!)
} else {
element.textContent = value?.title ?? label
}
element.innerHTML = value?.title ?? innerHtml
if (value.disabled) {
disabled = true

View File

@@ -73,28 +73,6 @@ describe('LinkConnector SubgraphInput connection validation', () => {
expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
})
it('should allow reconnection to same target', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const node = new LGraphNode('TargetNode')
node.addInput('number_in', 'number')
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const renderLink = new ToInputFromIoNodeLink(
subgraph,
subgraph.inputNode,
subgraph.inputNode.slots[0],
undefined,
LinkDirection.CENTER,
link
)
renderLink.connectToInput(node, node.inputs[0], connector.events)
expect(node.inputs[0].link).not.toBeNull()
})
})
describe('MovingOutputLink validation', () => {

View File

@@ -58,12 +58,6 @@ export class ToInputFromIoNodeLink implements RenderLink {
events: CustomEventTarget<LinkConnectorEventMap>
) {
const { fromSlot, fromReroute, existingLink } = this
if (
existingLink &&
node.id === existingLink.target_id &&
node.inputs[existingLink.target_slot] === input
)
return
const newLink = fromSlot.connect(input, node, fromReroute?.id)

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "عدم العرض مرة أخرى",
"missingModels": "نماذج مفقودة",
"missingModelsMessage": "عند تحميل الرسم البياني، لم يتم العثور على النماذج التالية",
"reEnableInSettings": "إعادة التفعيل في {link}",
"reEnableInSettingsLink": "الإعدادات"
"missingModelsMessage": "عند تحميل الرسم البياني، لم يتم العثور على النماذج التالية"
},
"missingNodes": {
"cloud": {
@@ -2809,9 +2807,6 @@
"vueNodesMigrationMainMenu": {
"message": "يمكنك العودة إلى Nodes 2.0 في أي وقت من القائمة الرئيسية."
},
"vueNodesSlot": {
"iterative": "(تكراري)"
},
"welcome": {
"getStarted": "ابدأ الآن",
"title": "مرحباً بك في ComfyUI"
@@ -2832,7 +2827,6 @@
"placeholder": "اختر...",
"placeholderAudio": "اختر صوت...",
"placeholderImage": "اختر صورة...",
"placeholderMesh": "اختر شبكة...",
"placeholderModel": "اختر نموذج...",
"placeholderUnknown": "اختر وسائط...",
"placeholderVideo": "اختر فيديو..."

View File

@@ -5024,46 +5024,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "إنشاء مقاطع فيديو باستخدام Kling V3 من خلال الإطارين الأول والأخير.",
"display_name": "Kling 3.0 الإطار الأول والأخير إلى فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"duration": {
"name": "المدة"
},
"end_frame": {
"name": "الإطار الأخير"
},
"first_frame": {
"name": "الإطار الأول"
},
"generate_audio": {
"name": "توليد الصوت"
},
"model": {
"name": "النموذج",
"tooltip": "إعدادات النموذج والتوليد."
},
"model_resolution": {
"name": "الدقة"
},
"prompt": {
"name": "الوصف"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "كليغ صورة إلى فيديو",
"inputs": {
@@ -5116,9 +5076,6 @@
"aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع"
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"human_fidelity": {
"name": "تشابه الموضوع",
"tooltip": "تشابه المرجع للموضوع البشري"
@@ -5147,10 +5104,6 @@
"prompt": {
"name": "نص التوجيه الإيجابي",
"tooltip": "نص التوجيه الإيجابي"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
@@ -5283,9 +5236,6 @@
"description": "حرر فيديو موجود باستخدام أحدث نموذج من Kling.",
"display_name": "تحرير فيديو Kling Omni (احترافي)",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"keep_original_sound": {
"name": "الاحتفاظ بالصوت الأصلي"
},
@@ -5303,10 +5253,6 @@
"resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"video": {
"name": "فيديو",
"tooltip": "الفيديو للتحرير. سيكون طول الفيديو الناتج هو نفسه."
@@ -5322,9 +5268,6 @@
"description": "استخدم إطار بداية، وإطار نهاية اختياري، أو صور مرجعية مع أحدث نموذج من Kling.",
"display_name": "Kling Omni من الإطار الأول إلى الأخير إلى فيديو (احترافي)",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"duration": {
"name": "duration"
},
@@ -5335,10 +5278,6 @@
"first_frame": {
"name": "first_frame"
},
"generate_audio": {
"name": "توليد الصوت",
"tooltip": "توليد صوت للفيديو. مدعوم فقط لـ kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5352,14 +5291,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"storyboards": {
"name": "لوحات القصة",
"tooltip": "إنشاء سلسلة من مقاطع الفيديو مع أوصاف ومدد فردية. مدعوم فقط لـ kling-v3-omni."
}
},
"outputs": {
@@ -5375,9 +5306,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model_name": {
"name": "model_name"
},
@@ -5391,14 +5319,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"series_amount": {
"name": "عدد السلاسل",
"tooltip": "إنشاء سلسلة من الصور. غير مدعوم لـ kling-image-o1."
}
},
"outputs": {
@@ -5414,16 +5334,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "توليد الصوت",
"tooltip": "توليد صوت للفيديو. مدعوم فقط لـ kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5437,14 +5350,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"storyboards": {
"name": "لوحات القصة",
"tooltip": "إنشاء سلسلة من مقاطع الفيديو مع أوصاف ومدد فردية. مدعوم فقط لـ kling-v3-omni."
}
},
"outputs": {
@@ -5460,15 +5365,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "توليد الصوت"
},
"model_name": {
"name": "model_name"
},
@@ -5478,14 +5377,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة تتحكم في ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"storyboards": {
"name": "لوحات القصة",
"tooltip": "إنشاء سلسلة من مقاطع الفيديو مع أوصاف ومدد فردية. يتم تجاهلها لنموذج o1."
}
},
"outputs": {
@@ -5501,9 +5392,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"duration": {
"name": "duration"
},
@@ -5527,10 +5415,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "البذرة",
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
@@ -5716,54 +5600,6 @@
}
}
},
"KlingVideoNode": {
"description": "إنشاء مقاطع فيديو باستخدام Kling V3. يدعم التحويل من نص إلى فيديو ومن صورة إلى فيديو مع إمكانية استخدام لوحة قصة متعددة التعليمات وخيار توليد الصوت.",
"display_name": "Kling 3.0 فيديو",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"generate_audio": {
"name": "توليد الصوت"
},
"model": {
"name": "النموذج",
"tooltip": "إعدادات النموذج والتوليد."
},
"model_aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع"
},
"model_resolution": {
"name": "الدقة"
},
"multi_shot": {
"name": "متعدد اللقطات",
"tooltip": "إنشاء سلسلة من مقاطع الفيديو مع تعليمات ومدة زمنية منفصلة لكل مقطع."
},
"multi_shot_duration": {
"name": "المدة"
},
"multi_shot_negative_prompt": {
"name": "تعليمة سلبية"
},
"multi_shot_prompt": {
"name": "تعليمة"
},
"seed": {
"name": "البذرة",
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
},
"start_frame": {
"name": "الإطار الابتدائي",
"tooltip": "صورة الإطار الابتدائي (اختياري). عند التوصيل، يتحول إلى وضع صورة إلى فيديو."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "عقدة تجربة الملابس الافتراضية من كليينج. أدخل صورة إنسان وصورة ملابس لتجربة الملابس على الإنسان.",
"display_name": "كليينج تجربة الملابس الافتراضية",
@@ -15435,31 +15271,6 @@
}
}
},
"Video Slice": {
"display_name": "تقطيع الفيديو",
"inputs": {
"duration": {
"name": "المدة",
"tooltip": "المدة بالثواني، أو 0 لمدة غير محدودة"
},
"start_time": {
"name": "وقت البدء",
"tooltip": "وقت البدء بالثواني"
},
"strict_duration": {
"name": "مدة صارمة",
"tooltip": "إذا كانت القيمة صحيحة، سيتم رفع خطأ عند عدم إمكانية تحقيق المدة المحددة."
},
"video": {
"name": "الفيديو"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "توجيه VideoLinearCFG",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "تمكين اختصار التكبير السريع (Ctrl + Shift + سحب)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "إزالة التكرار من معرفات العقد الفرعية",
"tooltip": "إعادة تعيين معرفات العقد المكررة تلقائيًا في العقد الفرعية عند تحميل سير العمل."
},
"Comfy_Graph_LinkMarkers": {
"name": "علامات منتصف الروابط",
"options": {

View File

@@ -755,6 +755,8 @@
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"noQueueItems": "No active jobs",
"noQueueItemsMessage": "Queue a prompt to see active jobs here",
"generatedAssetsHeader": "Generated assets",
"importedAssetsHeader": "Imported assets",
"activeJobStatus": "Active job: {status}",
@@ -1691,8 +1693,6 @@
},
"missingModelsDialog": {
"doNotAskAgain": "Don't show this again",
"reEnableInSettings": "Re-enable in {link}",
"reEnableInSettingsLink": "Settings",
"missingModels": "Missing Models",
"missingModelsMessage": "When loading the graph, the following models were not found"
},
@@ -1803,7 +1803,7 @@
},
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl, .ply, .spz, .splat, .ksplat)",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
"uploadingModel": "Uploading 3D model..."
},
"imageCrop": {
@@ -2389,7 +2389,6 @@
"placeholderImage": "Select image...",
"placeholderAudio": "Select audio...",
"placeholderVideo": "Select video...",
"placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
},
@@ -2834,9 +2833,6 @@
}
}
},
"vueNodesSlot": {
"iterative": "(Iterative)"
},
"vueNodesBanner": {
"title": "Introducing Nodes 2.0",
"desc": " More flexible workflows, powerful new widgets, built for extensibility",
@@ -2881,25 +2877,6 @@
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
}
},
"nodeReplacement": {
"quickFixAvailable": "Quick Fix Available",
"installationRequired": "Installation Required",
"compatibleAlternatives": "Compatible Alternatives",
"replaceable": "Replaceable",
"replaced": "Replaced",
"notReplaceable": "Install Required",
"selectAll": "Select All",
"replaceSelected": "Replace Selected ({count})",
"replacedNode": "Replaced node: {nodeType}",
"replacedAllNodes": "Replaced {count} node type(s)",
"replaceFailed": "Failed to replace nodes",
"instructionMessage": "You must install these nodes or replace them with installed alternatives to run the workflow. Missing nodes are highlighted in {red} on the canvas. Some nodes cannot be swapped and must be installed via Node Manager.",
"redHighlight": "red",
"openNodeManager": "Open Node Manager",
"skipForNow": "Skip for Now",
"installMissingNodes": "Install Missing Nodes",
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure."
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"noSelection": "Select a node to see its properties and info.",

View File

@@ -4921,46 +4921,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"display_name": "Kling 3.0 First-Last-Frame to Video",
"description": "Generate videos with Kling V3 using first and last frames.",
"inputs": {
"prompt": {
"name": "prompt"
},
"duration": {
"name": "duration"
},
"first_frame": {
"name": "first_frame"
},
"end_frame": {
"name": "end_frame"
},
"generate_audio": {
"name": "generate_audio"
},
"model": {
"name": "model",
"tooltip": "Model and generation settings."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
},
"model_resolution": {
"name": "resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling Image(First Frame) to Video",
"inputs": {
@@ -5007,7 +4967,7 @@
}
},
"KlingImageGenerationNode": {
"display_name": "Kling 3.0 Image",
"display_name": "Kling Image Generation",
"description": "Kling Image Generation Node. Generate an image from a text prompt with an optional reference image.",
"inputs": {
"prompt": {
@@ -5041,13 +5001,6 @@
},
"image": {
"name": "image"
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
@@ -5057,7 +5010,7 @@
}
},
"KlingImageToVideoWithAudio": {
"display_name": "Kling 2.6 Image(First Frame) to Video with Audio",
"display_name": "Kling Image(First Frame) to Video with Audio",
"inputs": {
"model_name": {
"name": "model_name"
@@ -5177,7 +5130,7 @@
}
},
"KlingOmniProEditVideoNode": {
"display_name": "Kling 3.0 Omni Edit Video",
"display_name": "Kling Omni Edit Video (Pro)",
"description": "Edit an existing video with the latest model from Kling.",
"inputs": {
"model_name": {
@@ -5200,13 +5153,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
@@ -5216,7 +5162,7 @@
}
},
"KlingOmniProFirstLastFrameNode": {
"display_name": "Kling 3.0 Omni First-Last-Frame to Video",
"display_name": "Kling Omni First-Last-Frame to Video (Pro)",
"description": "Use a start frame, an optional end frame, or reference images with the latest Kling model.",
"inputs": {
"model_name": {
@@ -5224,7 +5170,7 @@
},
"prompt": {
"name": "prompt",
"tooltip": "A text prompt describing the video content. This can include both positive and negative descriptions. Ignored when storyboards are enabled."
"tooltip": "A text prompt describing the video content. This can include both positive and negative descriptions."
},
"duration": {
"name": "duration"
@@ -5234,7 +5180,7 @@
},
"end_frame": {
"name": "end_frame",
"tooltip": "An optional end frame for the video. This cannot be used simultaneously with 'reference_images'. Does not work with storyboards."
"tooltip": "An optional end frame for the video. This cannot be used simultaneously with 'reference_images'."
},
"reference_images": {
"name": "reference_images",
@@ -5242,21 +5188,6 @@
},
"resolution": {
"name": "resolution"
},
"storyboards": {
"name": "storyboards",
"tooltip": "Generate a series of video segments with individual prompts and durations. Only supported for kling-v3-omni."
},
"generate_audio": {
"name": "generate_audio",
"tooltip": "Generate audio for the video. Only supported for kling-v3-omni."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
@@ -5266,7 +5197,7 @@
}
},
"KlingOmniProImageNode": {
"display_name": "Kling 3.0 Omni Image",
"display_name": "Kling Omni Image (Pro)",
"description": "Create or edit images with the latest model from Kling.",
"inputs": {
"model_name": {
@@ -5282,20 +5213,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"series_amount": {
"name": "series_amount",
"tooltip": "Generate a series of images. Not supported for kling-image-o1."
},
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 10 additional reference images."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
@@ -5305,7 +5225,7 @@
}
},
"KlingOmniProImageToVideoNode": {
"display_name": "Kling 3.0 Omni Image to Video",
"display_name": "Kling Omni Image to Video (Pro)",
"description": "Use up to 7 reference images to generate a video with the latest Kling model.",
"inputs": {
"model_name": {
@@ -5313,7 +5233,7 @@
},
"prompt": {
"name": "prompt",
"tooltip": "A text prompt describing the video content. This can include both positive and negative descriptions. Ignored when storyboards are enabled."
"tooltip": "A text prompt describing the video content. This can include both positive and negative descriptions."
},
"aspect_ratio": {
"name": "aspect_ratio"
@@ -5327,21 +5247,6 @@
},
"resolution": {
"name": "resolution"
},
"storyboards": {
"name": "storyboards",
"tooltip": "Generate a series of video segments with individual prompts and durations. Only supported for kling-v3-omni."
},
"generate_audio": {
"name": "generate_audio",
"tooltip": "Generate audio for the video. Only supported for kling-v3-omni."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
@@ -5351,7 +5256,7 @@
}
},
"KlingOmniProTextToVideoNode": {
"display_name": "Kling 3.0 Omni Text to Video",
"display_name": "Kling Omni Text to Video (Pro)",
"description": "Use text prompts to generate videos with the latest Kling model.",
"inputs": {
"model_name": {
@@ -5359,7 +5264,7 @@
},
"prompt": {
"name": "prompt",
"tooltip": "A text prompt describing the video content. This can include both positive and negative descriptions. Ignored when storyboards are enabled."
"tooltip": "A text prompt describing the video content. This can include both positive and negative descriptions."
},
"aspect_ratio": {
"name": "aspect_ratio"
@@ -5369,20 +5274,6 @@
},
"resolution": {
"name": "resolution"
},
"storyboards": {
"name": "storyboards",
"tooltip": "Generate a series of video segments with individual prompts and durations. Ignored for o1 model."
},
"generate_audio": {
"name": "generate_audio"
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
@@ -5392,7 +5283,7 @@
}
},
"KlingOmniProVideoToVideoNode": {
"display_name": "Kling 3.0 Omni Video to Video",
"display_name": "Kling Omni Video to Video (Pro)",
"description": "Use a video and up to 4 reference images to generate a video with the latest Kling model.",
"inputs": {
"model_name": {
@@ -5421,13 +5312,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
@@ -5551,7 +5435,7 @@
}
},
"KlingTextToVideoWithAudio": {
"display_name": "Kling 2.6 Text to Video with Audio",
"display_name": "Kling Text to Video with Audio",
"inputs": {
"model_name": {
"name": "model_name"
@@ -5613,54 +5497,6 @@
}
}
},
"KlingVideoNode": {
"display_name": "Kling 3.0 Video",
"description": "Generate videos with Kling V3. Supports text-to-video and image-to-video with optional storyboard multi-prompt and audio generation.",
"inputs": {
"multi_shot": {
"name": "multi_shot",
"tooltip": "Generate a series of video segments with individual prompts and durations."
},
"generate_audio": {
"name": "generate_audio"
},
"model": {
"name": "model",
"tooltip": "Model and generation settings."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"start_frame": {
"name": "start_frame",
"tooltip": "Optional start frame image. When connected, switches to image-to-video mode."
},
"control_after_generate": {
"name": "control after generate"
},
"model_aspect_ratio": {
"name": "aspect_ratio"
},
"model_resolution": {
"name": "resolution"
},
"multi_shot_duration": {
"name": "duration"
},
"multi_shot_negative_prompt": {
"name": "negative_prompt"
},
"multi_shot_prompt": {
"name": "prompt"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"display_name": "Kling Virtual Try On",
"description": "Kling Virtual Try On Node. Input a human image and a cloth image to try on the cloth on the human. You can merge multiple clothing item pictures into one image with a white background.",
@@ -15573,31 +15409,6 @@
}
}
},
"Video Slice": {
"display_name": "Video Slice",
"inputs": {
"video": {
"name": "video"
},
"start_time": {
"name": "start_time",
"tooltip": "Start time in seconds"
},
"duration": {
"name": "duration",
"tooltip": "Duration in seconds, or 0 for unlimited duration"
},
"strict_duration": {
"name": "strict_duration",
"tooltip": "If True, when the specified duration is not possible, an error will be raised."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "VideoLinearCFGGuidance",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "Enable fast-zoom shortcut (Ctrl + Shift + Drag)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "Deduplicate subgraph node IDs",
"tooltip": "Automatically reassign duplicate node IDs in subgraphs when loading a workflow."
},
"Comfy_Graph_LinkMarkers": {
"name": "Link midpoint markers",
"options": {
@@ -285,8 +281,8 @@
"name": "Show API node pricing badge"
},
"Comfy_NodeReplacement_Enabled": {
"name": "Enable node replacement suggestions",
"tooltip": "When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements."
"name": "Enable automatic node replacement",
"tooltip": "When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists."
},
"Comfy_NodeSearchBoxImpl": {
"name": "Node search box implementation",

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "No mostrar esto de nuevo",
"missingModels": "Modelos faltantes",
"missingModelsMessage": "Al cargar el gráfico, no se encontraron los siguientes modelos",
"reEnableInSettings": "Vuelve a habilitar en {link}",
"reEnableInSettingsLink": "Configuración"
"missingModelsMessage": "Al cargar el gráfico, no se encontraron los siguientes modelos"
},
"missingNodes": {
"cloud": {
@@ -2809,9 +2807,6 @@
"vueNodesMigrationMainMenu": {
"message": "Cambia a Nodes 2.0 en cualquier momento desde el menú principal."
},
"vueNodesSlot": {
"iterative": "(Iterativo)"
},
"welcome": {
"getStarted": "Empezar",
"title": "Bienvenido a ComfyUI"
@@ -2832,7 +2827,6 @@
"placeholder": "Seleccionar...",
"placeholderAudio": "Seleccionar audio...",
"placeholderImage": "Seleccionar imagen...",
"placeholderMesh": "Seleccionar malla...",
"placeholderModel": "Seleccionar modelo...",
"placeholderUnknown": "Seleccionar medio...",
"placeholderVideo": "Seleccionar video..."

View File

@@ -5024,46 +5024,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "Genera videos con Kling V3 usando el primer y último fotograma.",
"display_name": "Kling 3.0 Primer-Último Fotograma a Video",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"duration": {
"name": "duración"
},
"end_frame": {
"name": "último_fotograma"
},
"first_frame": {
"name": "primer_fotograma"
},
"generate_audio": {
"name": "generar_audio"
},
"model": {
"name": "modelo",
"tooltip": "Configuración del modelo y generación."
},
"model_resolution": {
"name": "resolución"
},
"prompt": {
"name": "prompt"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling Imagen a Video",
"inputs": {
@@ -5116,9 +5076,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar después de generar"
},
"human_fidelity": {
"name": "human_fidelity",
"tooltip": "Similitud de referencia del sujeto"
@@ -5147,10 +5104,6 @@
"prompt": {
"name": "prompt",
"tooltip": "Prompt de texto positivo"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
}
},
"outputs": {
@@ -5283,9 +5236,6 @@
"description": "Edita un video existente con el modelo más reciente de Kling.",
"display_name": "Kling Omni Editar Video (Pro)",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"keep_original_sound": {
"name": "mantener_sonido_original"
},
@@ -5303,10 +5253,6 @@
"resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"video": {
"name": "video",
"tooltip": "Video para editar. La longitud del video de salida será la misma."
@@ -5322,9 +5268,6 @@
"description": "Utiliza un fotograma inicial, un fotograma final opcional o imágenes de referencia con el último modelo de Kling.",
"display_name": "Kling Omni Primer-Último-Frame a Video (Pro)",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"duration": {
"name": "duration"
},
@@ -5335,10 +5278,6 @@
"first_frame": {
"name": "first_frame"
},
"generate_audio": {
"name": "generar_audio",
"tooltip": "Genera audio para el video. Solo compatible con kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5352,14 +5291,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Genera una serie de segmentos de video con prompts y duraciones individuales. Solo compatible con kling-v3-omni."
}
},
"outputs": {
@@ -5375,9 +5306,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar después de generar"
},
"model_name": {
"name": "model_name"
},
@@ -5391,14 +5319,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"series_amount": {
"name": "cantidad_de_series",
"tooltip": "Genera una serie de imágenes. No compatible con kling-image-o1."
}
},
"outputs": {
@@ -5414,16 +5334,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar después de generar"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "generar_audio",
"tooltip": "Genera audio para el video. Solo compatible con kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5437,14 +5350,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Genera una serie de segmentos de video con prompts y duraciones individuales. Solo compatible con kling-v3-omni."
}
},
"outputs": {
@@ -5460,15 +5365,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar después de generar"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "generar_audio"
},
"model_name": {
"name": "model_name"
},
@@ -5478,14 +5377,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Genera una serie de segmentos de video con prompts y duraciones individuales. Ignorado para el modelo o1."
}
},
"outputs": {
@@ -5501,9 +5392,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar después de generar"
},
"duration": {
"name": "duration"
},
@@ -5527,10 +5415,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe volver a ejecutarse; los resultados son no deterministas independientemente de la semilla."
}
},
"outputs": {
@@ -5716,54 +5600,6 @@
}
}
},
"KlingVideoNode": {
"description": "Genera videos con Kling V3. Soporta texto a video e imagen a video con opción de storyboard multiprompt y generación de audio.",
"display_name": "Kling 3.0 Video",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"generate_audio": {
"name": "generar audio"
},
"model": {
"name": "modelo",
"tooltip": "Configuración del modelo y generación."
},
"model_aspect_ratio": {
"name": "relación de aspecto"
},
"model_resolution": {
"name": "resolución"
},
"multi_shot": {
"name": "multi_shot",
"tooltip": "Genera una serie de segmentos de video con indicaciones y duraciones individuales."
},
"multi_shot_duration": {
"name": "duración"
},
"multi_shot_negative_prompt": {
"name": "indicaciones negativas"
},
"multi_shot_prompt": {
"name": "indicaciones"
},
"seed": {
"name": "semilla",
"tooltip": "La semilla controla si el nodo debe volver a ejecutarse; los resultados son no deterministas independientemente de la semilla."
},
"start_frame": {
"name": "fotograma inicial",
"tooltip": "Imagen de fotograma inicial opcional. Al conectarse, cambia al modo imagen a video."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Nodo Kling Virtual Try On. Ingresa una imagen de una persona y una imagen de una prenda para probar la prenda en la persona.",
"display_name": "Kling Virtual Try On",
@@ -15435,31 +15271,6 @@
}
}
},
"Video Slice": {
"display_name": "Corte de Video",
"inputs": {
"duration": {
"name": "duración",
"tooltip": "Duración en segundos, o 0 para duración ilimitada"
},
"start_time": {
"name": "hora de inicio",
"tooltip": "Hora de inicio en segundos"
},
"strict_duration": {
"name": "duración estricta",
"tooltip": "Si es Verdadero, cuando la duración especificada no sea posible, se generará un error."
},
"video": {
"name": "video"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "OrientaciónLinealCFGVideo",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "Habilitar atajo de zoom rápido (Ctrl + Shift + Arrastrar)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "Eliminar duplicados de IDs de nodos en subgráficos",
"tooltip": "Reasigna automáticamente los IDs de nodos duplicados en subgráficos al cargar un flujo de trabajo."
},
"Comfy_Graph_LinkMarkers": {
"name": "Marcadores de punto medio de enlace",
"options": {

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "دیگر نمایش داده نشود",
"missingModels": "مدل‌های مفقود",
"missingModelsMessage": "هنگام بارگذاری گراف، مدل‌های زیر یافت نشدند",
"reEnableInSettings": "فعال‌سازی مجدد در {link}",
"reEnableInSettingsLink": "تنظیمات"
"missingModelsMessage": "هنگام بارگذاری گراف، مدل‌های زیر یافت نشدند"
},
"missingNodes": {
"cloud": {
@@ -2820,9 +2818,6 @@
"vueNodesMigrationMainMenu": {
"message": "در هر زمان می‌توانید از منوی اصلی به Nodes 2.0 بازگردید."
},
"vueNodesSlot": {
"iterative": "(تکراری)"
},
"welcome": {
"getStarted": "شروع کنید",
"title": "به ComfyUI خوش آمدید"
@@ -2843,7 +2838,6 @@
"placeholder": "انتخاب...",
"placeholderAudio": "انتخاب صوت...",
"placeholderImage": "انتخاب تصویر...",
"placeholderMesh": "مش را انتخاب کنید...",
"placeholderModel": "انتخاب مدل...",
"placeholderUnknown": "انتخاب رسانه...",
"placeholderVideo": "انتخاب ویدیو..."

View File

@@ -5033,46 +5033,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "تولید ویدیو با Kling V3 با استفاده از اولین و آخرین فریم.",
"display_name": "Kling 3.0 تبدیل اولین و آخرین فریم به ویدیو",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"duration": {
"name": "مدت زمان"
},
"end_frame": {
"name": "آخرین فریم"
},
"first_frame": {
"name": "اولین فریم"
},
"generate_audio": {
"name": "تولید صدا"
},
"model": {
"name": "مدل",
"tooltip": "تنظیمات مدل و تولید."
},
"model_resolution": {
"name": "رزولوشن"
},
"prompt": {
"name": "پرامپت"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج حتی با seed یکسان غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "تبدیل تصویر Kling (فریم اول) به ویدیو",
"inputs": {
@@ -5125,9 +5085,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"human_fidelity": {
"name": "human_fidelity",
"tooltip": "شباهت مرجع سوژه"
@@ -5156,10 +5113,6 @@
"prompt": {
"name": "prompt",
"tooltip": "پرامپت متنی مثبت"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج حتی با seed یکسان غیرقطعی هستند."
}
},
"outputs": {
@@ -5292,9 +5245,6 @@
"description": "ویرایش یک ویدیوی موجود با جدیدترین مدل Kling.",
"display_name": "ویرایش ویدیوی Omni Kling (حرفه‌ای)",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"keep_original_sound": {
"name": "حفظ صدای اصلی"
},
@@ -5312,10 +5262,6 @@
"resolution": {
"name": "وضوح"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج حتی با seed یکسان غیرقطعی هستند."
},
"video": {
"name": "ویدیو",
"tooltip": "ویدیو برای ویرایش. طول ویدیوی خروجی همانند ورودی خواهد بود."
@@ -5331,9 +5277,6 @@
"description": "استفاده از یک فریم شروع، یک فریم پایانی اختیاری یا تصاویر مرجع با جدیدترین مدل Kling.",
"display_name": "Kling Omni تبدیل اولین-آخرین فریم به ویدیو (Pro)",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"duration": {
"name": "duration"
},
@@ -5344,10 +5287,6 @@
"first_frame": {
"name": "first_frame"
},
"generate_audio": {
"name": "تولید صدا",
"tooltip": "تولید صدا برای ویدیو. فقط برای kling-v3-omni پشتیبانی می‌شود."
},
"model_name": {
"name": "model_name"
},
@@ -5361,14 +5300,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج حتی با seed یکسان غیرقطعی هستند."
},
"storyboards": {
"name": "استوری‌بوردها",
"tooltip": "تولید مجموعه‌ای از بخش‌های ویدیویی با پرامپت و مدت زمان جداگانه. فقط برای kling-v3-omni پشتیبانی می‌شود."
}
},
"outputs": {
@@ -5384,9 +5315,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model_name": {
"name": "model_name"
},
@@ -5400,14 +5328,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج حتی با seed یکسان غیرقطعی هستند."
},
"series_amount": {
"name": "تعداد سری",
"tooltip": "تولید مجموعه‌ای از تصاویر. برای kling-image-o1 پشتیبانی نمی‌شود."
}
},
"outputs": {
@@ -5423,16 +5343,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "تولید صدا",
"tooltip": "تولید صدا برای ویدیو. فقط برای kling-v3-omni پشتیبانی می‌شود."
},
"model_name": {
"name": "model_name"
},
@@ -5446,14 +5359,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج حتی با seed یکسان غیرقطعی هستند."
},
"storyboards": {
"name": "استوری‌بوردها",
"tooltip": "تولید مجموعه‌ای از بخش‌های ویدیویی با پرامپت و مدت زمان جداگانه. فقط برای kling-v3-omni پشتیبانی می‌شود."
}
},
"outputs": {
@@ -5469,15 +5374,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "تولید صدا"
},
"model_name": {
"name": "model_name"
},
@@ -5487,14 +5386,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که node باید دوباره اجرا شود یا خیر؛ نتایج حتی با seed یکسان غیرقطعی هستند."
},
"storyboards": {
"name": "استوری‌بوردها",
"tooltip": "تولید مجموعه‌ای از بخش‌های ویدیویی با پرامپت و مدت زمان جداگانه. برای مدل o1 نادیده گرفته می‌شود."
}
},
"outputs": {
@@ -5510,9 +5401,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"duration": {
"name": "duration"
},
@@ -5536,10 +5424,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که آیا node باید دوباره اجرا شود؛ نتایج صرف‌نظر از seed غیرقطعی هستند."
}
},
"outputs": {
@@ -5725,54 +5609,6 @@
}
}
},
"KlingVideoNode": {
"description": "تولید ویدیو با Kling V3. پشتیبانی از تبدیل متن به ویدیو و تصویر به ویدیو با امکان استفاده از استوری‌بورد چندپرومپتی و تولید صوت اختیاری.",
"display_name": "Kling 3.0 ویدیو",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"generate_audio": {
"name": "تولید صوت"
},
"model": {
"name": "مدل",
"tooltip": "تنظیمات مدل و تولید."
},
"model_aspect_ratio": {
"name": "نسبت تصویر"
},
"model_resolution": {
"name": "رزولوشن"
},
"multi_shot": {
"name": "چند شات",
"tooltip": "تولید مجموعه‌ای از بخش‌های ویدیویی با پرومپت‌ها و مدت زمان‌های جداگانه."
},
"multi_shot_duration": {
"name": "مدت زمان"
},
"multi_shot_negative_prompt": {
"name": "پرومپت منفی"
},
"multi_shot_prompt": {
"name": "پرومپت"
},
"seed": {
"name": "seed",
"tooltip": "seed تعیین می‌کند که آیا node باید دوباره اجرا شود؛ نتایج صرف‌نظر از seed غیرقطعی هستند."
},
"start_frame": {
"name": "فریم شروع",
"tooltip": "تصویر فریم شروع اختیاری. در صورت اتصال، به حالت تصویر به ویدیو تغییر می‌کند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "گره Kling پرو لباس مجازی. یک تصویر انسان و یک تصویر لباس وارد کنید تا لباس را روی انسان امتحان کنید. می‌توانید چندین تصویر لباس را در یک تصویر با پس‌زمینه سفید ادغام کنید.",
"display_name": "Kling پرو لباس مجازی",
@@ -15448,31 +15284,6 @@
}
}
},
"Video Slice": {
"display_name": "برش ویدیو",
"inputs": {
"duration": {
"name": "مدت زمان",
"tooltip": "مدت زمان بر حسب ثانیه، یا ۰ برای مدت نامحدود"
},
"start_time": {
"name": "زمان شروع",
"tooltip": "زمان شروع بر حسب ثانیه"
},
"strict_duration": {
"name": "مدت زمان دقیق",
"tooltip": "اگر True باشد، در صورت عدم امکان مدت زمان مشخص شده، خطا نمایش داده می‌شود."
},
"video": {
"name": "ویدیو"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "VideoLinearCFGGuidance",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "فعال‌سازی میانبر بزرگ‌نمایی سریع (Ctrl + Shift + کشیدن)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "حذف شناسه‌های تکراری node در زیرگراف",
"tooltip": "شناسه‌های تکراری node در زیرگراف‌ها هنگام بارگذاری workflow به‌صورت خودکار دوباره اختصاص داده می‌شوند."
},
"Comfy_Graph_LinkMarkers": {
"name": "نشانگرهای میانه‌ی پیوند",
"options": {

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "Ne plus afficher ce message",
"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"
"missingModelsMessage": "Lors du chargement du graphique, les modèles suivants n'ont pas été trouvés"
},
"missingNodes": {
"cloud": {
@@ -2809,9 +2807,6 @@
"vueNodesMigrationMainMenu": {
"message": "Revenez à Nodes 2.0 à tout moment depuis le menu principal."
},
"vueNodesSlot": {
"iterative": "(Itératif)"
},
"welcome": {
"getStarted": "Commencer",
"title": "Bienvenue sur ComfyUI"
@@ -2832,7 +2827,6 @@
"placeholder": "Sélectionner...",
"placeholderAudio": "Sélectionner un audio...",
"placeholderImage": "Sélectionner une image...",
"placeholderMesh": "Sélectionner un mesh...",
"placeholderModel": "Sélectionner un modèle...",
"placeholderUnknown": "Sélectionner un média...",
"placeholderVideo": "Sélectionner une vidéo..."

View File

@@ -5024,46 +5024,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "Générez des vidéos avec Kling V3 en utilisant la première et la dernière image.",
"display_name": "Kling 3.0 Première-Dernière-Image en Vidéo",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "durée"
},
"end_frame": {
"name": "dernière image"
},
"first_frame": {
"name": "première image"
},
"generate_audio": {
"name": "générer l'audio"
},
"model": {
"name": "modèle",
"tooltip": "Paramètres du modèle et de la génération."
},
"model_resolution": {
"name": "résolution"
},
"prompt": {
"name": "prompt"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling Image to Video",
"inputs": {
@@ -5116,9 +5076,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "contrôle après génération"
},
"human_fidelity": {
"name": "human_fidelity",
"tooltip": "Similarité de référence du sujet"
@@ -5147,10 +5104,6 @@
"prompt": {
"name": "prompt",
"tooltip": "Invite textuelle positive"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
}
},
"outputs": {
@@ -5283,9 +5236,6 @@
"description": "Éditez une vidéo existante avec le dernier modèle de Kling.",
"display_name": "Kling Omni Édition Vidéo (Pro)",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"keep_original_sound": {
"name": "garder_son_original"
},
@@ -5303,10 +5253,6 @@
"resolution": {
"name": "résolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"video": {
"name": "vidéo",
"tooltip": "Vidéo à éditer. La longueur de la vidéo de sortie sera la même."
@@ -5322,9 +5268,6 @@
"description": "Utilisez une image de départ, une image de fin optionnelle ou des images de référence avec le dernier modèle Kling.",
"display_name": "Kling Omni Première-Dernière-Image vers Vidéo (Pro)",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "duration"
},
@@ -5335,10 +5278,6 @@
"first_frame": {
"name": "first_frame"
},
"generate_audio": {
"name": "générer l'audio",
"tooltip": "Générer l'audio pour la vidéo. Pris en charge uniquement pour kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5352,14 +5291,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Générez une série de segments vidéo avec des prompts et des durées individuelles. Pris en charge uniquement pour kling-v3-omni."
}
},
"outputs": {
@@ -5375,9 +5306,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "contrôle après génération"
},
"model_name": {
"name": "model_name"
},
@@ -5391,14 +5319,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"series_amount": {
"name": "nombre de séries",
"tooltip": "Générez une série d'images. Non pris en charge pour kling-image-o1."
}
},
"outputs": {
@@ -5414,16 +5334,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "générer l'audio",
"tooltip": "Générer l'audio pour la vidéo. Pris en charge uniquement pour kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5437,14 +5350,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Générez une série de segments vidéo avec des prompts et des durées individuelles. Pris en charge uniquement pour kling-v3-omni."
}
},
"outputs": {
@@ -5460,15 +5365,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "générer l'audio"
},
"model_name": {
"name": "model_name"
},
@@ -5478,14 +5377,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Générez une série de segments vidéo avec des prompts et des durées individuelles. Ignoré pour le modèle o1."
}
},
"outputs": {
@@ -5501,9 +5392,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "duration"
},
@@ -5527,10 +5415,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
}
},
"outputs": {
@@ -5716,54 +5600,6 @@
}
}
},
"KlingVideoNode": {
"description": "Générez des vidéos avec Kling V3. Prend en charge le texte-vers-vidéo et limage-vers-vidéo avec storyboard multi-invite optionnel et génération audio.",
"display_name": "Kling 3.0 Vidéo",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"generate_audio": {
"name": "générer audio"
},
"model": {
"name": "modèle",
"tooltip": "Paramètres du modèle et de la génération."
},
"model_aspect_ratio": {
"name": "ratio daspect"
},
"model_resolution": {
"name": "résolution"
},
"multi_shot": {
"name": "multi_shot",
"tooltip": "Générez une série de segments vidéo avec des invites et des durées individuelles."
},
"multi_shot_duration": {
"name": "durée"
},
"multi_shot_negative_prompt": {
"name": "invite_négative"
},
"multi_shot_prompt": {
"name": "invite"
},
"seed": {
"name": "seed",
"tooltip": "Le seed contrôle si le nœud doit être relancé ; les résultats sont non déterministes quel que soit le seed."
},
"start_frame": {
"name": "image de départ",
"tooltip": "Image de départ optionnelle. Lorsquelle est connectée, passe en mode image-vers-vidéo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Nœud Kling Virtual Try On. Importez une image humaine et une image de vêtement pour essayer le vêtement sur la personne.",
"display_name": "Kling Virtual Try On",
@@ -15435,31 +15271,6 @@
}
}
},
"Video Slice": {
"display_name": "Découpage Vidéo",
"inputs": {
"duration": {
"name": "durée",
"tooltip": "Durée en secondes, ou 0 pour une durée illimitée"
},
"start_time": {
"name": "heure_de_début",
"tooltip": "Heure de début en secondes"
},
"strict_duration": {
"name": "durée_stricte",
"tooltip": "Si vrai, lorsquil nest pas possible de respecter la durée spécifiée, une erreur sera générée."
},
"video": {
"name": "vidéo"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "Guidance VideoLinearCFG",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "Activer le raccourci de zoom rapide (Ctrl + Shift + Glisser)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "Dédupliquer les identifiants de nœuds de sous-graphe",
"tooltip": "Réattribue automatiquement les identifiants de nœuds dupliqués dans les sous-graphes lors du chargement dun flux de travail."
},
"Comfy_Graph_LinkMarkers": {
"name": "Marqueurs de point médian du lien",
"options": {

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "再度表示しない",
"missingModels": "モデルが見つかりません",
"missingModelsMessage": "グラフを読み込む際に、次のモデルが見つかりませんでした",
"reEnableInSettings": "{link}で再有効化",
"reEnableInSettingsLink": "設定"
"missingModelsMessage": "グラフを読み込む際に、次のモデルが見つかりませんでした"
},
"missingNodes": {
"cloud": {
@@ -2809,9 +2807,6 @@
"vueNodesMigrationMainMenu": {
"message": "メインメニューからいつでもNodes 2.0に戻せます。"
},
"vueNodesSlot": {
"iterative": "(反復)"
},
"welcome": {
"getStarted": "はじめる",
"title": "ComfyUIへようこそ"
@@ -2832,7 +2827,6 @@
"placeholder": "選択...",
"placeholderAudio": "音声を選択...",
"placeholderImage": "画像を選択...",
"placeholderMesh": "メッシュを選択...",
"placeholderModel": "モデルを選択...",
"placeholderUnknown": "メディアを選択...",
"placeholderVideo": "動画を選択..."

View File

@@ -5024,46 +5024,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "Kling V3 を使用して最初と最後のフレームからビデオを生成します。",
"display_name": "Kling 3.0 ファースト・ラストフレームからビデオ生成",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "継続時間"
},
"end_frame": {
"name": "最後のフレーム"
},
"first_frame": {
"name": "最初のフレーム"
},
"generate_audio": {
"name": "音声を生成"
},
"model": {
"name": "モデル",
"tooltip": "モデルと生成設定。"
},
"model_resolution": {
"name": "解像度"
},
"prompt": {
"name": "プロンプト"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードを再実行するかどうかを制御します。シードに関係なく結果は非決定的です。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling 画像から動画へ",
"inputs": {
@@ -5116,9 +5076,6 @@
"aspect_ratio": {
"name": "アスペクト比"
},
"control_after_generate": {
"name": "生成後のコントロール"
},
"human_fidelity": {
"name": "人物忠実度",
"tooltip": "被写体の参照類似度"
@@ -5147,10 +5104,6 @@
"prompt": {
"name": "プロンプト",
"tooltip": "ポジティブテキストプロンプト"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードを再実行するかどうかを制御します。シードに関係なく結果は非決定的です。"
}
},
"outputs": {
@@ -5283,9 +5236,6 @@
"description": "Klingの最新モデルで既存のビデオを編集します。",
"display_name": "Kling Omniビデオ編集Pro",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"keep_original_sound": {
"name": "元の音声を保持"
},
@@ -5303,10 +5253,6 @@
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードを再実行するかどうかを制御します。シードに関係なく結果は非決定的です。"
},
"video": {
"name": "ビデオ",
"tooltip": "編集するビデオ。出力ビデオの長さは同じになります。"
@@ -5322,9 +5268,6 @@
"description": "開始フレーム、オプションの終了フレーム、またはリファレンス画像を使用して最新のKlingモデルでビデオを生成します。",
"display_name": "Kling Omni ファースト・ラストフレームからビデオへ (Pro)",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "継続時間"
},
@@ -5335,10 +5278,6 @@
"first_frame": {
"name": "開始フレーム"
},
"generate_audio": {
"name": "音声を生成",
"tooltip": "ビデオ用の音声を生成します。kling-v3-omni のみ対応。"
},
"model_name": {
"name": "model_name"
},
@@ -5352,14 +5291,6 @@
},
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードを再実行するかどうかを制御します。シードに関係なく結果は非決定的です。"
},
"storyboards": {
"name": "ストーリーボード",
"tooltip": "個別のプロンプトと継続時間で一連のビデオセグメントを生成します。kling-v3-omni のみ対応。"
}
},
"outputs": {
@@ -5375,9 +5306,6 @@
"aspect_ratio": {
"name": "アスペクト比"
},
"control_after_generate": {
"name": "生成後のコントロール"
},
"model_name": {
"name": "model_name"
},
@@ -5391,14 +5319,6 @@
},
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードを再実行するかどうかを制御します。シードに関係なく結果は非決定的です。"
},
"series_amount": {
"name": "シリーズ数",
"tooltip": "一連の画像を生成します。kling-image-o1 では非対応。"
}
},
"outputs": {
@@ -5414,16 +5334,9 @@
"aspect_ratio": {
"name": "アスペクト比"
},
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "継続時間"
},
"generate_audio": {
"name": "音声を生成",
"tooltip": "ビデオ用の音声を生成します。kling-v3-omni のみ対応。"
},
"model_name": {
"name": "model_name"
},
@@ -5437,14 +5350,6 @@
},
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードを再実行するかどうかを制御します。シードに関係なく結果は非決定的です。"
},
"storyboards": {
"name": "ストーリーボード",
"tooltip": "個別のプロンプトと継続時間で一連のビデオセグメントを生成します。kling-v3-omni のみ対応。"
}
},
"outputs": {
@@ -5460,15 +5365,9 @@
"aspect_ratio": {
"name": "アスペクト比"
},
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "継続時間"
},
"generate_audio": {
"name": "音声を生成"
},
"model_name": {
"name": "model_name"
},
@@ -5478,14 +5377,6 @@
},
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードを再実行するかどうかを制御します。シードに関係なく結果は非決定的です。"
},
"storyboards": {
"name": "ストーリーボード",
"tooltip": "個別のプロンプトと継続時間で一連のビデオセグメントを生成します。o1 モデルでは無視されます。"
}
},
"outputs": {
@@ -5501,9 +5392,6 @@
"aspect_ratio": {
"name": "アスペクト比"
},
"control_after_generate": {
"name": "生成後のコントロール"
},
"duration": {
"name": "長さ"
},
@@ -5527,10 +5415,6 @@
},
"resolution": {
"name": "解像度"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードの再実行を制御します。シードに関係なく結果は非決定的です。"
}
},
"outputs": {
@@ -5716,54 +5600,6 @@
}
}
},
"KlingVideoNode": {
"description": "Kling V3でビデオを生成します。テキストからビデオ、画像からビデオ、オプションでストーリーボードのマルチプロンプトやオーディオ生成に対応しています。",
"display_name": "Kling 3.0 ビデオ",
"inputs": {
"control_after_generate": {
"name": "生成後のコントロール"
},
"generate_audio": {
"name": "オーディオ生成"
},
"model": {
"name": "モデル",
"tooltip": "モデルと生成設定。"
},
"model_aspect_ratio": {
"name": "アスペクト比"
},
"model_resolution": {
"name": "解像度"
},
"multi_shot": {
"name": "マルチショット",
"tooltip": "個別のプロンプトと時間で一連のビデオセグメントを生成します。"
},
"multi_shot_duration": {
"name": "時間"
},
"multi_shot_negative_prompt": {
"name": "ネガティブプロンプト"
},
"multi_shot_prompt": {
"name": "プロンプト"
},
"seed": {
"name": "シード",
"tooltip": "シードはノードの再実行を制御します。シードに関係なく結果は非決定的です。"
},
"start_frame": {
"name": "開始フレーム",
"tooltip": "オプションの開始フレーム画像。接続すると画像からビデオモードに切り替わります。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Klingバーチャル試着ード。人物画像と服画像を入力して、人物に服を試着させます。",
"display_name": "Klingバーチャル試着",
@@ -15435,31 +15271,6 @@
}
}
},
"Video Slice": {
"display_name": "ビデオスライス",
"inputs": {
"duration": {
"name": "時間",
"tooltip": "時間、0の場合は無制限"
},
"start_time": {
"name": "開始時間",
"tooltip": "開始時間(秒)"
},
"strict_duration": {
"name": "厳密な時間",
"tooltip": "Trueの場合、指定した時間が不可能な場合はエラーになります。"
},
"video": {
"name": "ビデオ"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "ビデオ線形CFGガイダンス",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "ファストズームショートカットを有効にするCtrl + Shift + ドラッグ)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "サブグラフードIDの重複排除",
"tooltip": "ワークフローを読み込む際に、サブグラフ内の重複したードIDを自動的に再割り当てします。"
},
"Comfy_Graph_LinkMarkers": {
"name": "リンク中点マーカー",
"options": {

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
"missingModels": "모델이 없습니다",
"missingModelsMessage": "그래프를 로드할 때 다음 모델을 찾을 수 없었습니다",
"reEnableInSettings": "{link}에서 다시 활성화",
"reEnableInSettingsLink": "설정"
"missingModelsMessage": "그래프를 로드할 때 다음 모델을 찾을 수 없었습니다"
},
"missingNodes": {
"cloud": {
@@ -2809,9 +2807,6 @@
"vueNodesMigrationMainMenu": {
"message": "메인 메뉴에서 언제든지 Nodes 2.0으로 다시 전환할 수 있습니다."
},
"vueNodesSlot": {
"iterative": "(반복)"
},
"welcome": {
"getStarted": "시작하기",
"title": "ComfyUI에 오신 것을 환영합니다"
@@ -2832,7 +2827,6 @@
"placeholder": "선택...",
"placeholderAudio": "오디오 선택...",
"placeholderImage": "이미지 선택...",
"placeholderMesh": "메시 선택...",
"placeholderModel": "모델 선택...",
"placeholderUnknown": "미디어 선택...",
"placeholderVideo": "비디오 선택..."

View File

@@ -5024,46 +5024,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "Kling V3를 사용하여 첫 프레임과 마지막 프레임으로 비디오를 생성합니다.",
"display_name": "Kling 3.0 첫-마지막 프레임에서 비디오 생성",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "지속 시간"
},
"end_frame": {
"name": "마지막 프레임"
},
"first_frame": {
"name": "첫 프레임"
},
"generate_audio": {
"name": "오디오 생성"
},
"model": {
"name": "모델",
"tooltip": "모델 및 생성 설정."
},
"model_resolution": {
"name": "해상도"
},
"prompt": {
"name": "프롬프트"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling 비디오 생성 (이미지 → 비디오)",
"inputs": {
@@ -5116,9 +5076,6 @@
"aspect_ratio": {
"name": "종횡비"
},
"control_after_generate": {
"name": "생성 후 제어"
},
"human_fidelity": {
"name": "사람 충실도",
"tooltip": "피사체 참조 유사도"
@@ -5147,10 +5104,6 @@
"prompt": {
"name": "프롬프트",
"tooltip": "이미지 생성을 위한 프롬프트"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
}
},
"outputs": {
@@ -5283,9 +5236,6 @@
"description": "Kling의 최신 모델로 기존 비디오를 편집합니다.",
"display_name": "Kling 옴니 비디오 편집 (Pro)",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"keep_original_sound": {
"name": "원본 사운드 유지"
},
@@ -5303,10 +5253,6 @@
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
},
"video": {
"name": "비디오",
"tooltip": "편집할 비디오입니다. 출력 비디오 길이는 동일합니다."
@@ -5322,9 +5268,6 @@
"description": "최신 Kling 모델을 사용하여 시작 프레임, 선택적 종료 프레임 또는 참조 이미지를 사용합니다.",
"display_name": "Kling Omni 첫-마지막 프레임에서 비디오로 (Pro)",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "지속 시간"
},
@@ -5335,10 +5278,6 @@
"first_frame": {
"name": "시작 프레임"
},
"generate_audio": {
"name": "오디오 생성",
"tooltip": "비디오용 오디오를 생성합니다. kling-v3-omni에서만 지원됩니다."
},
"model_name": {
"name": "model_name"
},
@@ -5352,14 +5291,6 @@
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
},
"storyboards": {
"name": "스토리보드",
"tooltip": "각각의 프롬프트와 지속 시간으로 비디오 세그먼트 시리즈를 생성합니다. kling-v3-omni에서만 지원됩니다."
}
},
"outputs": {
@@ -5375,9 +5306,6 @@
"aspect_ratio": {
"name": "화면 비율"
},
"control_after_generate": {
"name": "생성 후 제어"
},
"model_name": {
"name": "model_name"
},
@@ -5391,14 +5319,6 @@
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
},
"series_amount": {
"name": "시리즈 개수",
"tooltip": "이미지 시리즈를 생성합니다. kling-image-o1에서는 지원되지 않습니다."
}
},
"outputs": {
@@ -5414,16 +5334,9 @@
"aspect_ratio": {
"name": "화면 비율"
},
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "지속 시간"
},
"generate_audio": {
"name": "오디오 생성",
"tooltip": "비디오용 오디오를 생성합니다. kling-v3-omni에서만 지원됩니다."
},
"model_name": {
"name": "model_name"
},
@@ -5437,14 +5350,6 @@
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
},
"storyboards": {
"name": "스토리보드",
"tooltip": "각각의 프롬프트와 지속 시간으로 비디오 세그먼트 시리즈를 생성합니다. kling-v3-omni에서만 지원됩니다."
}
},
"outputs": {
@@ -5460,15 +5365,9 @@
"aspect_ratio": {
"name": "화면 비율"
},
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "지속 시간"
},
"generate_audio": {
"name": "오디오 생성"
},
"model_name": {
"name": "model_name"
},
@@ -5478,14 +5377,6 @@
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행될지 여부를 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
},
"storyboards": {
"name": "스토리보드",
"tooltip": "각각의 프롬프트와 지속 시간으로 비디오 세그먼트 시리즈를 생성합니다. o1 모델에서는 무시됩니다."
}
},
"outputs": {
@@ -5501,9 +5392,6 @@
"aspect_ratio": {
"name": "종횡비"
},
"control_after_generate": {
"name": "생성 후 제어"
},
"duration": {
"name": "길이"
},
@@ -5527,10 +5415,6 @@
},
"resolution": {
"name": "해상도"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행되어야 하는지 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
}
},
"outputs": {
@@ -5716,54 +5600,6 @@
}
}
},
"KlingVideoNode": {
"description": "Kling V3로 비디오를 생성합니다. 텍스트-투-비디오, 이미지-투-비디오, 선택적 스토리보드 멀티 프롬프트 및 오디오 생성을 지원합니다.",
"display_name": "Kling 3.0 비디오",
"inputs": {
"control_after_generate": {
"name": "생성 후 제어"
},
"generate_audio": {
"name": "오디오 생성"
},
"model": {
"name": "모델",
"tooltip": "모델 및 생성 설정."
},
"model_aspect_ratio": {
"name": "종횡비"
},
"model_resolution": {
"name": "해상도"
},
"multi_shot": {
"name": "멀티 샷",
"tooltip": "각각의 프롬프트와 지속 시간을 가진 비디오 세그먼트 시리즈를 생성합니다."
},
"multi_shot_duration": {
"name": "지속 시간"
},
"multi_shot_negative_prompt": {
"name": "네거티브 프롬프트"
},
"multi_shot_prompt": {
"name": "프롬프트"
},
"seed": {
"name": "시드",
"tooltip": "시드는 노드가 다시 실행되어야 하는지 제어합니다. 시드와 관계없이 결과는 비결정적입니다."
},
"start_frame": {
"name": "시작 프레임",
"tooltip": "선택적 시작 프레임 이미지입니다. 연결 시 이미지-투-비디오 모드로 전환됩니다."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Kling 가상 착용 노드입니다. 인물 이미지와 의류 이미지를 입력하여 인물에게 의류를 착용시킵니다.",
"display_name": "Kling 가상 착용",
@@ -15435,31 +15271,6 @@
}
}
},
"Video Slice": {
"display_name": "비디오 슬라이스",
"inputs": {
"duration": {
"name": "지속 시간",
"tooltip": "초 단위 지속 시간, 0은 무제한 지속"
},
"start_time": {
"name": "시작 시간",
"tooltip": "초 단위 시작 시간"
},
"strict_duration": {
"name": "엄격한 지속 시간",
"tooltip": "True로 설정 시, 지정한 지속 시간이 불가능하면 오류가 발생합니다."
},
"video": {
"name": "비디오"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "비디오 선형 CFG 가이드",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "빠른 확대/축소 단축키 활성화 (Ctrl + Shift + 드래그)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "서브그래프 노드 ID 중복 제거",
"tooltip": "워크플로우를 불러올 때 서브그래프 내 중복된 노드 ID를 자동으로 재할당합니다."
},
"Comfy_Graph_LinkMarkers": {
"name": "링크 중간점 마커",
"options": {
@@ -249,8 +245,8 @@
"name": "API 노드 가격 배지 표시"
},
"Comfy_NodeReplacement_Enabled": {
"name": "노드 교체 제안 활성화",
"tooltip": "활성화하면, 교체 매핑이 존재하는 누락 노드가 교체 가능으로 표시되어 검토 후 교체할 수 있습니다."
"name": "자동 노드 교체 활성화",
"tooltip": "활성화하면, 누락된 노드를 교체 매핑이 존재할 경우 최신 버전의 노드로 자동 교체할 수 있습니다."
},
"Comfy_NodeSearchBoxImpl": {
"name": "노드 검색 상자 구현",

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "Não mostrar novamente",
"missingModels": "Modelos ausentes",
"missingModelsMessage": "Ao carregar o grafo, os seguintes modelos não foram encontrados",
"reEnableInSettings": "Reativar em {link}",
"reEnableInSettingsLink": "Configurações"
"missingModelsMessage": "Ao carregar o grafo, os seguintes modelos não foram encontrados"
},
"missingNodes": {
"cloud": {
@@ -2820,9 +2818,6 @@
"vueNodesMigrationMainMenu": {
"message": "Volte para Nodes 2.0 a qualquer momento pelo menu principal."
},
"vueNodesSlot": {
"iterative": "(Iterativo)"
},
"welcome": {
"getStarted": "Começar",
"title": "Bem-vindo ao ComfyUI"
@@ -2843,7 +2838,6 @@
"placeholder": "Selecionar...",
"placeholderAudio": "Selecionar áudio...",
"placeholderImage": "Selecionar imagem...",
"placeholderMesh": "Selecionar malha...",
"placeholderModel": "Selecionar modelo...",
"placeholderUnknown": "Selecionar mídia...",
"placeholderVideo": "Selecionar vídeo..."

View File

@@ -5033,46 +5033,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "Gere vídeos com Kling V3 usando o primeiro e o último frame.",
"display_name": "Kling 3.0 Primeiro-Último-Frame para Vídeo",
"inputs": {
"control_after_generate": {
"name": "controlar após gerar"
},
"duration": {
"name": "duração"
},
"end_frame": {
"name": "último_frame"
},
"first_frame": {
"name": "primeiro_frame"
},
"generate_audio": {
"name": "gerar_áudio"
},
"model": {
"name": "modelo",
"tooltip": "Configurações de modelo e geração."
},
"model_resolution": {
"name": "resolução"
},
"prompt": {
"name": "prompt"
},
"seed": {
"name": "semente",
"tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling Imagem (Primeiro Quadro) para Vídeo",
"inputs": {
@@ -5125,9 +5085,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar após gerar"
},
"human_fidelity": {
"name": "human_fidelity",
"tooltip": "Similaridade de referência do sujeito"
@@ -5156,10 +5113,6 @@
"prompt": {
"name": "prompt",
"tooltip": "Prompt de texto positivo"
},
"seed": {
"name": "semente",
"tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente."
}
},
"outputs": {
@@ -5292,9 +5245,6 @@
"description": "Edite um vídeo existente com o modelo mais recente da Kling.",
"display_name": "Kling Omni Editar Vídeo (Pro)",
"inputs": {
"control_after_generate": {
"name": "controlar após gerar"
},
"keep_original_sound": {
"name": "manter_som_original"
},
@@ -5312,10 +5262,6 @@
"resolution": {
"name": "resolução"
},
"seed": {
"name": "semente",
"tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente."
},
"video": {
"name": "vídeo",
"tooltip": "Vídeo para edição. O comprimento do vídeo de saída será o mesmo."
@@ -5331,9 +5277,6 @@
"description": "Use um quadro inicial, um quadro final opcional ou imagens de referência com o modelo mais recente da Kling.",
"display_name": "Kling Omni Quadro Inicial-Final para Vídeo (Pro)",
"inputs": {
"control_after_generate": {
"name": "controlar após gerar"
},
"duration": {
"name": "duration"
},
@@ -5344,10 +5287,6 @@
"first_frame": {
"name": "first_frame"
},
"generate_audio": {
"name": "gerar_áudio",
"tooltip": "Gerar áudio para o vídeo. Suportado apenas para kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5361,14 +5300,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semente",
"tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Gere uma série de segmentos de vídeo com prompts e durações individuais. Suportado apenas para kling-v3-omni."
}
},
"outputs": {
@@ -5384,9 +5315,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar após gerar"
},
"model_name": {
"name": "model_name"
},
@@ -5400,14 +5328,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semente",
"tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente."
},
"series_amount": {
"name": "quantidade_de_séries",
"tooltip": "Gere uma série de imagens. Não suportado para kling-image-o1."
}
},
"outputs": {
@@ -5423,16 +5343,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar após gerar"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "gerar_áudio",
"tooltip": "Gerar áudio para o vídeo. Suportado apenas para kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5446,14 +5359,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semente",
"tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Gere uma série de segmentos de vídeo com prompts e durações individuais. Suportado apenas para kling-v3-omni."
}
},
"outputs": {
@@ -5469,15 +5374,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar após gerar"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "gerar_áudio"
},
"model_name": {
"name": "model_name"
},
@@ -5487,14 +5386,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "semente",
"tooltip": "A semente controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente da semente."
},
"storyboards": {
"name": "storyboards",
"tooltip": "Gere uma série de segmentos de vídeo com prompts e durações individuais. Ignorado para o modelo o1."
}
},
"outputs": {
@@ -5510,9 +5401,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "controlar após gerar"
},
"duration": {
"name": "duration"
},
@@ -5536,10 +5424,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "O seed controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente do seed."
}
},
"outputs": {
@@ -5725,54 +5609,6 @@
}
}
},
"KlingVideoNode": {
"description": "Gere vídeos com Kling V3. Suporta texto-para-vídeo e imagem-para-vídeo com storyboard opcional, multi-prompt e geração de áudio.",
"display_name": "Kling 3.0 Vídeo",
"inputs": {
"control_after_generate": {
"name": "controlar após gerar"
},
"generate_audio": {
"name": "gerar áudio"
},
"model": {
"name": "modelo",
"tooltip": "Configurações de modelo e geração."
},
"model_aspect_ratio": {
"name": "proporção"
},
"model_resolution": {
"name": "resolução"
},
"multi_shot": {
"name": "multi_shot",
"tooltip": "Gere uma série de segmentos de vídeo com prompts e durações individuais."
},
"multi_shot_duration": {
"name": "duração"
},
"multi_shot_negative_prompt": {
"name": "prompt_negativo"
},
"multi_shot_prompt": {
"name": "prompt"
},
"seed": {
"name": "seed",
"tooltip": "O seed controla se o nó deve ser executado novamente; os resultados são não determinísticos independentemente do seed."
},
"start_frame": {
"name": "quadro inicial",
"tooltip": "Quadro inicial opcional. Quando conectado, alterna para o modo imagem-para-vídeo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Nó Kling Prova Virtual. Insira uma imagem de uma pessoa e uma imagem de roupa para experimentar a roupa na pessoa. Você pode mesclar várias imagens de peças de roupa em uma única imagem com fundo branco.",
"display_name": "Kling Prova Virtual",
@@ -15448,31 +15284,6 @@
}
}
},
"Video Slice": {
"display_name": "Corte de Vídeo",
"inputs": {
"duration": {
"name": "duração",
"tooltip": "Duração em segundos, ou 0 para duração ilimitada"
},
"start_time": {
"name": "início",
"tooltip": "Tempo de início em segundos"
},
"strict_duration": {
"name": "duração_estrita",
"tooltip": "Se Verdadeiro, quando a duração especificada não for possível, um erro será exibido."
},
"video": {
"name": "vídeo"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "VideoLinearCFGGuidance",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "Ativar atalho de zoom rápido (Ctrl + Shift + Arrastar)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "Desduplicar IDs de nós de subgrafos",
"tooltip": "Reatribui automaticamente IDs de nós duplicados em subgrafos ao carregar um fluxo de trabalho."
},
"Comfy_Graph_LinkMarkers": {
"name": "Marcadores de meio do link",
"options": {

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "Больше не показывать это",
"missingModels": "Отсутствующие модели",
"missingModelsMessage": "При загрузке графа следующие модели не были найдены",
"reEnableInSettings": "Включить снова в {link}",
"reEnableInSettingsLink": "Настройки"
"missingModelsMessage": "При загрузке графа следующие модели не были найдены"
},
"missingNodes": {
"cloud": {
@@ -2809,9 +2807,6 @@
"vueNodesMigrationMainMenu": {
"message": "Переключиться обратно на Nodes 2.0 можно в главном меню."
},
"vueNodesSlot": {
"iterative": "(Итеративно)"
},
"welcome": {
"getStarted": "Начать",
"title": "Добро пожаловать в ComfyUI"
@@ -2832,7 +2827,6 @@
"placeholder": "Выбрать...",
"placeholderAudio": "Выбрать аудио...",
"placeholderImage": "Выбрать изображение...",
"placeholderMesh": "Выберите mesh...",
"placeholderModel": "Выбрать модель...",
"placeholderUnknown": "Выбрать медиа...",
"placeholderVideo": "Выбрать видео..."

View File

@@ -5024,46 +5024,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "Создавайте видео с помощью Kling V3, используя первый и последний кадры.",
"display_name": "Kling 3.0: Видео по первому и последнему кадру",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"duration": {
"name": "длительность"
},
"end_frame": {
"name": "последний кадр"
},
"first_frame": {
"name": "первый кадр"
},
"generate_audio": {
"name": "создать аудио"
},
"model": {
"name": "модель",
"tooltip": "Настройки модели и генерации."
},
"model_resolution": {
"name": "разрешение"
},
"prompt": {
"name": "prompt"
},
"seed": {
"name": "seed",
"tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling Image to Video",
"inputs": {
@@ -5116,9 +5076,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "контроль после генерации"
},
"human_fidelity": {
"name": "human_fidelity",
"tooltip": "Сходство с референсом субъекта"
@@ -5147,10 +5104,6 @@
"prompt": {
"name": "prompt",
"tooltip": "Положительный текстовый запрос"
},
"seed": {
"name": "seed",
"tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed."
}
},
"outputs": {
@@ -5283,9 +5236,6 @@
"description": "Редактируйте существующее видео с помощью последней модели от Kling.",
"display_name": "Kling: Omni редактирование видео (Pro)",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"keep_original_sound": {
"name": "сохранить_оригинальный_звук"
},
@@ -5303,10 +5253,6 @@
"resolution": {
"name": "разрешение"
},
"seed": {
"name": "seed",
"tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed."
},
"video": {
"name": "видео",
"tooltip": "Видео для редактирования. Длина выходного видео будет такой же."
@@ -5322,9 +5268,6 @@
"description": "Используйте начальный кадр, необязательный конечный кадр или референсные изображения с новейшей моделью Kling.",
"display_name": "Kling Omni Первый-Последний Кадр в Видео (Pro)",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"duration": {
"name": "duration"
},
@@ -5335,10 +5278,6 @@
"first_frame": {
"name": "first_frame"
},
"generate_audio": {
"name": "создать аудио",
"tooltip": "Создать аудио для видео. Поддерживается только для kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5352,14 +5291,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed."
},
"storyboards": {
"name": "раскадровки",
"tooltip": "Создайте серию видеосегментов с индивидуальными подсказками и длительностью. Поддерживается только для kling-v3-omni."
}
},
"outputs": {
@@ -5375,9 +5306,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "контроль после генерации"
},
"model_name": {
"name": "model_name"
},
@@ -5391,14 +5319,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed."
},
"series_amount": {
"name": "количество серий",
"tooltip": "Создать серию изображений. Не поддерживается для kling-image-o1."
}
},
"outputs": {
@@ -5414,16 +5334,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "контроль после генерации"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "создать аудио",
"tooltip": "Создать аудио для видео. Поддерживается только для kling-v3-omni."
},
"model_name": {
"name": "model_name"
},
@@ -5437,14 +5350,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed."
},
"storyboards": {
"name": "раскадровки",
"tooltip": "Создайте серию видеосегментов с индивидуальными подсказками и длительностью. Поддерживается только для kling-v3-omni."
}
},
"outputs": {
@@ -5460,15 +5365,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "контроль после генерации"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "создать аудио"
},
"model_name": {
"name": "model_name"
},
@@ -5478,14 +5377,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed определяет, нужно ли повторно запускать узел; результаты всегда недетерминированы, независимо от seed."
},
"storyboards": {
"name": "раскадровки",
"tooltip": "Создайте серию видеосегментов с индивидуальными подсказками и длительностью. Игнорируется для модели o1."
}
},
"outputs": {
@@ -5501,9 +5392,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "контроль после генерации"
},
"duration": {
"name": "duration"
},
@@ -5527,10 +5415,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Сид управляет тем, должен ли узел выполняться повторно; результаты остаются недетерминированными независимо от значения сида."
}
},
"outputs": {
@@ -5716,54 +5600,6 @@
}
}
},
"KlingVideoNode": {
"description": "Генерируйте видео с помощью Kling V3. Поддерживает текст-видео и изображение-видео с дополнительной раскадровкой, мультипромптом и генерацией аудио.",
"display_name": "Kling 3.0 Видео",
"inputs": {
"control_after_generate": {
"name": "контроль после генерации"
},
"generate_audio": {
"name": "генерировать аудио"
},
"model": {
"name": "модель",
"tooltip": "Настройки модели и генерации."
},
"model_aspect_ratio": {
"name": "соотношение сторон"
},
"model_resolution": {
"name": "разрешение"
},
"multi_shot": {
"name": "мульти-шот",
"tooltip": "Создайте серию видеосегментов с индивидуальными промптами и длительностью."
},
"multi_shot_duration": {
"name": "длительность"
},
"multi_shot_negative_prompt": {
"name": "негативный промпт"
},
"multi_shot_prompt": {
"name": "промпт"
},
"seed": {
"name": "seed",
"tooltip": "Сид управляет тем, должен ли узел выполняться повторно; результаты остаются недетерминированными независимо от значения сида."
},
"start_frame": {
"name": "стартовый кадр",
"tooltip": "Необязательное стартовое изображение кадра. При подключении переключается в режим изображение-видео."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Узел Kling Виртуальная Примерка. Введите изображение человека и изображение одежды, чтобы примерить одежду на человеке.",
"display_name": "Kling Виртуальная Примерка",
@@ -15435,31 +15271,6 @@
}
}
},
"Video Slice": {
"display_name": "Видеофрагмент",
"inputs": {
"duration": {
"name": "длительность",
"tooltip": "Длительность в секундах, или 0 для неограниченной длительности"
},
"start_time": {
"name": "время начала",
"tooltip": "Время начала в секундах"
},
"strict_duration": {
"name": "строгая длительность",
"tooltip": "Если Истина, при невозможности указанной длительности будет вызвана ошибка."
},
"video": {
"name": "видео"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "Направление Video Linear CFG",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "Включить быстрый зум с помощью сочетания клавиш (Ctrl + Shift + Колёсико мыши)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "Удалить дубликаты идентификаторов узлов подграфа",
"tooltip": "Автоматически переназначать повторяющиеся идентификаторы узлов в подграфах при загрузке рабочего процесса."
},
"Comfy_Graph_LinkMarkers": {
"name": "Маркер середины ссылки",
"options": {

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "Bunu bir daha gösterme",
"missingModels": "Eksik Modeller",
"missingModelsMessage": "Grafik yüklenirken aşağıdaki modeller bulunamadı",
"reEnableInSettings": "{link} içinde tekrar etkinleştir",
"reEnableInSettingsLink": "Ayarlar"
"missingModelsMessage": "Grafik yüklenirken aşağıdaki modeller bulunamadı"
},
"missingNodes": {
"cloud": {
@@ -2809,9 +2807,6 @@
"vueNodesMigrationMainMenu": {
"message": "Ana menüden istediğiniz zaman Nodes 2.0'a geri dönebilirsiniz."
},
"vueNodesSlot": {
"iterative": "(Yinelemeli)"
},
"welcome": {
"getStarted": "Başlayın",
"title": "ComfyUI'ye Hoş Geldiniz"
@@ -2832,7 +2827,6 @@
"placeholder": "Seç...",
"placeholderAudio": "Ses seç...",
"placeholderImage": "Görsel seç...",
"placeholderMesh": "Ağ seç...",
"placeholderModel": "Model seç...",
"placeholderUnknown": "Medya seç...",
"placeholderVideo": "Video seç..."

View File

@@ -5024,46 +5024,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "Kling V3 ile ilk ve son kareleri kullanarak video oluşturun.",
"display_name": "Kling 3.0 İlk-Son-Kareden Videoya",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "süre"
},
"end_frame": {
"name": "son_kare"
},
"first_frame": {
"name": "ilk_kare"
},
"generate_audio": {
"name": "ses_oluştur"
},
"model": {
"name": "model",
"tooltip": "Model ve oluşturma ayarları."
},
"model_resolution": {
"name": "çözünürlük"
},
"prompt": {
"name": "prompt"
},
"seed": {
"name": "seed",
"tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling Görüntüden Videoya",
"inputs": {
@@ -5116,9 +5076,6 @@
"aspect_ratio": {
"name": "en_boy_oranı"
},
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"human_fidelity": {
"name": "insan_sadakati",
"tooltip": "Konu referans benzerliği"
@@ -5147,10 +5104,6 @@
"prompt": {
"name": "istem",
"tooltip": "Pozitif metin istemi"
},
"seed": {
"name": "seed",
"tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir."
}
},
"outputs": {
@@ -5283,9 +5236,6 @@
"description": "Mevcut bir videoyu Kling'in en son modeliyle düzenleyin.",
"display_name": "Kling Omni Video Düzenle (Pro)",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"keep_original_sound": {
"name": "orijinal_sesi_koru"
},
@@ -5303,10 +5253,6 @@
"resolution": {
"name": "çözünürlük"
},
"seed": {
"name": "seed",
"tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir."
},
"video": {
"name": "video",
"tooltip": "Düzenlenecek video. Çıktı video uzunluğu aynı olacaktır."
@@ -5322,9 +5268,6 @@
"description": "Başlangıç karesi, isteğe bağlı bir bitiş karesi veya referans görselleri ile en yeni Kling modeli kullanılır.",
"display_name": "Kling Omni İlk-Son-Kare'den Videoya (Pro)",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "duration"
},
@@ -5335,10 +5278,6 @@
"first_frame": {
"name": "first_frame"
},
"generate_audio": {
"name": "ses_oluştur",
"tooltip": "Video için ses oluşturun. Sadece kling-v3-omni için desteklenir."
},
"model_name": {
"name": "model_name"
},
@@ -5352,14 +5291,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir."
},
"storyboards": {
"name": "hikaye_tahtaları",
"tooltip": "Bireysel promptlar ve sürelerle bir dizi video segmenti oluşturun. Sadece kling-v3-omni için desteklenir."
}
},
"outputs": {
@@ -5375,9 +5306,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"model_name": {
"name": "model_name"
},
@@ -5391,14 +5319,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir."
},
"series_amount": {
"name": "seri_sayısı",
"tooltip": "Bir dizi görsel oluşturun. kling-image-o1 için desteklenmez."
}
},
"outputs": {
@@ -5414,16 +5334,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "ses_oluştur",
"tooltip": "Video için ses oluşturun. Sadece kling-v3-omni için desteklenir."
},
"model_name": {
"name": "model_name"
},
@@ -5437,14 +5350,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir."
},
"storyboards": {
"name": "hikaye_tahtaları",
"tooltip": "Bireysel promptlar ve sürelerle bir dizi video segmenti oluşturun. Sadece kling-v3-omni için desteklenir."
}
},
"outputs": {
@@ -5460,15 +5365,9 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "duration"
},
"generate_audio": {
"name": "ses_oluştur"
},
"model_name": {
"name": "model_name"
},
@@ -5478,14 +5377,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "seed",
"tooltip": "Seed, düğümün tekrar çalıştırılıp çalıştırılmayacağını kontrol eder; seed ne olursa olsun sonuçlar deterministik değildir."
},
"storyboards": {
"name": "hikaye_tahtaları",
"tooltip": "Bireysel promptlar ve sürelerle bir dizi video segmenti oluşturun. o1 modeli için dikkate alınmaz."
}
},
"outputs": {
@@ -5501,9 +5392,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"duration": {
"name": "duration"
},
@@ -5527,10 +5415,6 @@
},
"resolution": {
"name": "resolution"
},
"seed": {
"name": "tohum",
"tooltip": "Tohum, düğümün yeniden çalıştırılıp çalıştırılmayacağını kontrol eder; sonuçlar tohumdan bağımsız olarak belirlenemezdir."
}
},
"outputs": {
@@ -5716,54 +5600,6 @@
}
}
},
"KlingVideoNode": {
"description": "Kling V3 ile videolar oluşturun. Metinden videoya ve isteğe bağlı storyboard çoklu istem ve ses üretimiyle görüntüden videoya destekler.",
"display_name": "Kling 3.0 Video",
"inputs": {
"control_after_generate": {
"name": "oluşturduktan sonra kontrol et"
},
"generate_audio": {
"name": "ses oluştur"
},
"model": {
"name": "model",
"tooltip": "Model ve üretim ayarları."
},
"model_aspect_ratio": {
"name": "en boy oranı"
},
"model_resolution": {
"name": "çözünürlük"
},
"multi_shot": {
"name": "çoklu çekim",
"tooltip": "Her biri ayrı istem ve süreye sahip bir dizi video segmenti oluşturun."
},
"multi_shot_duration": {
"name": "süre"
},
"multi_shot_negative_prompt": {
"name": "negatif istem"
},
"multi_shot_prompt": {
"name": "istem"
},
"seed": {
"name": "tohum",
"tooltip": "Tohum, düğümün yeniden çalıştırılıp çalıştırılmayacağını kontrol eder; sonuçlar tohumdan bağımsız olarak belirlenemezdir."
},
"start_frame": {
"name": "başlangıç karesi",
"tooltip": "İsteğe bağlı başlangıç kare görüntüsü. Bağlandığında, görüntüden videoya moduna geçer."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Kling Sanal Deneme Düğümü. İnsan üzerine kıyafet denemek için bir insan resmi ve bir kıyafet resmi girin.",
"display_name": "Kling Sanal Deneme",
@@ -15435,31 +15271,6 @@
}
}
},
"Video Slice": {
"display_name": "Video Dilimle",
"inputs": {
"duration": {
"name": "süre",
"tooltip": "Süre (saniye cinsinden) veya sınırsız süre için 0"
},
"start_time": {
"name": "başlangıç zamanı",
"tooltip": "Başlangıç zamanı (saniye cinsinden)"
},
"strict_duration": {
"name": "kesin süre",
"tooltip": "Doğruysa, belirtilen süre mümkün değilse bir hata oluşur."
},
"video": {
"name": "video"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "VideoDoğrusalCFGRehberliği",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "Hızlı yakınlaştırma kısayolunu etkinleştir (Ctrl + Shift + Sürükle)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "Alt grafik düğüm kimliklerini çoğaltmayı önle",
"tooltip": "Bir iş akışı yüklenirken alt grafiklerdeki yinelenen düğüm kimliklerini otomatik olarak yeniden ata."
},
"Comfy_Graph_LinkMarkers": {
"name": "Bağlantı orta nokta işaretçileri",
"options": {

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "不要再顯示此訊息",
"missingModels": "缺少模型",
"missingModelsMessage": "載入圖形時,找不到以下模型",
"reEnableInSettings": "請在{link}中重新啟用",
"reEnableInSettingsLink": "設定"
"missingModelsMessage": "載入圖形時,找不到以下模型"
},
"missingNodes": {
"cloud": {
@@ -2809,9 +2807,6 @@
"vueNodesMigrationMainMenu": {
"message": "隨時可從主選單切換回 Nodes 2.0。"
},
"vueNodesSlot": {
"iterative": "(反覆運算)"
},
"welcome": {
"getStarted": "開始使用",
"title": "歡迎使用 ComfyUI"
@@ -2832,7 +2827,6 @@
"placeholder": "選擇...",
"placeholderAudio": "選擇音訊...",
"placeholderImage": "選擇圖片...",
"placeholderMesh": "選擇網格...",
"placeholderModel": "選擇模型...",
"placeholderUnknown": "選擇媒體...",
"placeholderVideo": "選擇影片..."

View File

@@ -5024,46 +5024,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "使用 Kling V3 以首尾影格產生影片。",
"display_name": "Kling 3.0 首尾影格轉影片",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
"end_frame": {
"name": "尾影格"
},
"first_frame": {
"name": "首影格"
},
"generate_audio": {
"name": "產生音訊"
},
"model": {
"name": "模型",
"tooltip": "模型與生成設定。"
},
"model_resolution": {
"name": "解析度"
},
"prompt": {
"name": "提示詞"
},
"seed": {
"name": "種子",
"tooltip": "種子決定此節點是否需重新執行;無論種子為何,結果皆為非決定性。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling 圖像轉影片",
"inputs": {
@@ -5116,9 +5076,6 @@
"aspect_ratio": {
"name": "aspect_ratio"
},
"control_after_generate": {
"name": "生成後控制"
},
"human_fidelity": {
"name": "human_fidelity",
"tooltip": "主體參考相似度"
@@ -5147,10 +5104,6 @@
"prompt": {
"name": "prompt",
"tooltip": "正向文字提示"
},
"seed": {
"name": "種子",
"tooltip": "種子決定此節點是否需重新執行;無論種子為何,結果皆為非決定性。"
}
},
"outputs": {
@@ -5283,9 +5236,6 @@
"description": "使用 Kling 最新模型編輯現有影片。",
"display_name": "Kling Omni 編輯影片(專業版)",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"keep_original_sound": {
"name": "保留原始音訊"
},
@@ -5303,10 +5253,6 @@
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子",
"tooltip": "種子決定此節點是否需重新執行;無論種子為何,結果皆為非決定性。"
},
"video": {
"name": "影片",
"tooltip": "要編輯的影片。輸出影片長度將與原影片相同。"
@@ -5322,9 +5268,6 @@
"description": "使用起始影格、可選的結束影格,或參考圖片,搭配最新 Kling 模型。",
"display_name": "Kling Omni 首末影格轉影片 (Pro)",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
@@ -5335,10 +5278,6 @@
"first_frame": {
"name": "起始影格"
},
"generate_audio": {
"name": "產生音訊",
"tooltip": "為影片產生音訊。僅支援 kling-v3-omni。"
},
"model_name": {
"name": "model_name"
},
@@ -5352,14 +5291,6 @@
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子",
"tooltip": "種子決定此節點是否需重新執行;無論種子為何,結果皆為非決定性。"
},
"storyboards": {
"name": "分鏡腳本",
"tooltip": "以個別提示詞與時長產生一系列影片片段。僅支援 kling-v3-omni。"
}
},
"outputs": {
@@ -5375,9 +5306,6 @@
"aspect_ratio": {
"name": "長寬比"
},
"control_after_generate": {
"name": "生成後控制"
},
"model_name": {
"name": "model_name"
},
@@ -5391,14 +5319,6 @@
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子",
"tooltip": "種子決定此節點是否需重新執行;無論種子為何,結果皆為非決定性。"
},
"series_amount": {
"name": "系列數量",
"tooltip": "產生一系列影像。不支援 kling-image-o1。"
}
},
"outputs": {
@@ -5414,16 +5334,9 @@
"aspect_ratio": {
"name": "長寬比"
},
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
"generate_audio": {
"name": "產生音訊",
"tooltip": "為影片產生音訊。僅支援 kling-v3-omni。"
},
"model_name": {
"name": "model_name"
},
@@ -5437,14 +5350,6 @@
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子",
"tooltip": "種子決定此節點是否需重新執行;無論種子為何,結果皆為非決定性。"
},
"storyboards": {
"name": "分鏡腳本",
"tooltip": "以個別提示詞與時長產生一系列影片片段。僅支援 kling-v3-omni。"
}
},
"outputs": {
@@ -5460,15 +5365,9 @@
"aspect_ratio": {
"name": "長寬比"
},
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
"generate_audio": {
"name": "產生音訊"
},
"model_name": {
"name": "model_name"
},
@@ -5478,14 +5377,6 @@
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "種子",
"tooltip": "種子決定此節點是否需重新執行;無論種子為何,結果皆為非決定性。"
},
"storyboards": {
"name": "分鏡腳本",
"tooltip": "以個別提示詞與時長產生一系列影片片段。o1 模型將忽略此設定。"
}
},
"outputs": {
@@ -5501,9 +5392,6 @@
"aspect_ratio": {
"name": "長寬比"
},
"control_after_generate": {
"name": "生成後控制"
},
"duration": {
"name": "時長"
},
@@ -5527,10 +5415,6 @@
},
"resolution": {
"name": "解析度"
},
"seed": {
"name": "seed",
"tooltip": "Seed 控制此節點是否重新執行;無論 seed 為何,結果皆為非確定性。"
}
},
"outputs": {
@@ -5716,54 +5600,6 @@
}
}
},
"KlingVideoNode": {
"description": "使用 Kling V3 產生影片。支援文字轉影片與圖片轉影片,可選擇分鏡多提示與音訊生成。",
"display_name": "Kling 3.0 影片",
"inputs": {
"control_after_generate": {
"name": "生成後控制"
},
"generate_audio": {
"name": "生成音訊"
},
"model": {
"name": "模型",
"tooltip": "模型與生成設定。"
},
"model_aspect_ratio": {
"name": "長寬比"
},
"model_resolution": {
"name": "解析度"
},
"multi_shot": {
"name": "多段生成",
"tooltip": "以個別提示與時長產生一系列影片片段。"
},
"multi_shot_duration": {
"name": "時長"
},
"multi_shot_negative_prompt": {
"name": "負面提示"
},
"multi_shot_prompt": {
"name": "提示"
},
"seed": {
"name": "seed",
"tooltip": "Seed 控制此節點是否重新執行;無論 seed 為何,結果皆為非確定性。"
},
"start_frame": {
"name": "起始畫格",
"tooltip": "可選的起始畫格圖片。連接後將切換為圖片轉影片模式。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Kling 虛擬試穿節點。輸入一張人物圖片和一張服裝圖片,將服裝試穿在人物身上。",
"display_name": "Kling 虛擬試穿",
@@ -15435,31 +15271,6 @@
}
}
},
"Video Slice": {
"display_name": "影片切片",
"inputs": {
"duration": {
"name": "時長",
"tooltip": "以秒為單位的時長0 代表無限時長"
},
"start_time": {
"name": "起始時間",
"tooltip": "以秒為單位的起始時間"
},
"strict_duration": {
"name": "嚴格時長",
"tooltip": "若為 True當指定時長無法達成時將產生錯誤。"
},
"video": {
"name": "影片"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "影片線性 CFG 引導",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "啟用快速縮放快捷鍵Ctrl + Shift + 拖曳)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "去重子圖節點 ID",
"tooltip": "載入工作流程時,自動重新分配子圖中重複的節點 ID。"
},
"Comfy_Graph_LinkMarkers": {
"name": "連結中點標記",
"options": {

View File

@@ -1740,9 +1740,7 @@
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
"missingModels": "缺少模型",
"missingModelsMessage": "加载工作流时,未找到以下模型",
"reEnableInSettings": "可在{link}中重新启用",
"reEnableInSettingsLink": "设置"
"missingModelsMessage": "加载工作流时,未找到以下模型"
},
"missingNodes": {
"cloud": {
@@ -2820,9 +2818,6 @@
"vueNodesMigrationMainMenu": {
"message": "在主菜单中随时切换回 Nodes 2.0"
},
"vueNodesSlot": {
"iterative": "(迭代)"
},
"welcome": {
"getStarted": "开始使用",
"title": "欢迎使用 ComfyUI"
@@ -2843,7 +2838,6 @@
"placeholder": "请选择...",
"placeholderAudio": "请选择音频...",
"placeholderImage": "请选择图片...",
"placeholderMesh": "选择网格...",
"placeholderModel": "请选择模型...",
"placeholderUnknown": "请选择媒体...",
"placeholderVideo": "请选择视频..."

View File

@@ -5033,46 +5033,6 @@
}
}
},
"KlingFirstLastFrameNode": {
"description": "使用 Kling V3 通过首帧和末帧生成视频。",
"display_name": "Kling 3.0 首帧-末帧生成视频",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "时长"
},
"end_frame": {
"name": "末帧"
},
"first_frame": {
"name": "首帧"
},
"generate_audio": {
"name": "生成音频"
},
"model": {
"name": "模型",
"tooltip": "模型与生成设置。"
},
"model_resolution": {
"name": "分辨率"
},
"prompt": {
"name": "提示词"
},
"seed": {
"name": "种子",
"tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingImage2VideoNode": {
"display_name": "Kling 图像转视频",
"inputs": {
@@ -5125,9 +5085,6 @@
"aspect_ratio": {
"name": "宽高比"
},
"control_after_generate": {
"name": "生成后控制"
},
"human_fidelity": {
"name": "主体参考强度",
"tooltip": "主体参考相似度"
@@ -5156,10 +5113,6 @@
"prompt": {
"name": "提示词",
"tooltip": "正向文本提示"
},
"seed": {
"name": "种子",
"tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。"
}
},
"outputs": {
@@ -5292,9 +5245,6 @@
"description": "使用 Kling 最新模型编辑视频。",
"display_name": "Kling Omni 编辑视频 (Pro)",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"keep_original_sound": {
"name": "保留原音频"
},
@@ -5312,10 +5262,6 @@
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "种子",
"tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。"
},
"video": {
"name": "视频",
"tooltip": "需要编辑的视频,输出视频的时长和输入视频相同。"
@@ -5331,9 +5277,6 @@
"description": "使用 Kling 最新模型和起始帧、可选的结束帧或参考图像。",
"display_name": "Kling Omni 首尾帧到视频 (Pro)",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "时长"
},
@@ -5344,10 +5287,6 @@
"first_frame": {
"name": "起始帧"
},
"generate_audio": {
"name": "生成音频",
"tooltip": "为视频生成音频。仅支持 kling-v3-omni。"
},
"model_name": {
"name": "模型"
},
@@ -5361,14 +5300,6 @@
},
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "种子",
"tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。"
},
"storyboards": {
"name": "分镜",
"tooltip": "生成一系列带有独立提示词和时长的视频片段。仅支持 kling-v3-omni。"
}
},
"outputs": {
@@ -5384,9 +5315,6 @@
"aspect_ratio": {
"name": "宽高比"
},
"control_after_generate": {
"name": "生成后控制"
},
"model_name": {
"name": "模型"
},
@@ -5400,14 +5328,6 @@
},
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "种子",
"tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。"
},
"series_amount": {
"name": "系列数量",
"tooltip": "生成一系列图像。不支持 kling-image-o1。"
}
},
"outputs": {
@@ -5423,16 +5343,9 @@
"aspect_ratio": {
"name": "宽高比"
},
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "时长"
},
"generate_audio": {
"name": "生成音频",
"tooltip": "为视频生成音频。仅支持 kling-v3-omni。"
},
"model_name": {
"name": "模型"
},
@@ -5446,14 +5359,6 @@
},
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "种子",
"tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。"
},
"storyboards": {
"name": "分镜",
"tooltip": "生成一系列带有独立提示词和时长的视频片段。仅支持 kling-v3-omni。"
}
},
"outputs": {
@@ -5469,15 +5374,9 @@
"aspect_ratio": {
"name": "宽高比"
},
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "时长"
},
"generate_audio": {
"name": "生成音频"
},
"model_name": {
"name": "模型"
},
@@ -5487,14 +5386,6 @@
},
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "种子",
"tooltip": "种子控制节点是否重新运行;无论种子如何,结果都是非确定性的。"
},
"storyboards": {
"name": "分镜",
"tooltip": "生成一系列带有独立提示词和时长的视频片段。o1 模型将忽略此项。"
}
},
"outputs": {
@@ -5510,9 +5401,6 @@
"aspect_ratio": {
"name": "宽高比"
},
"control_after_generate": {
"name": "生成后控制"
},
"duration": {
"name": "时长"
},
@@ -5536,10 +5424,6 @@
},
"resolution": {
"name": "分辨率"
},
"seed": {
"name": "seed",
"tooltip": "Seed 控制节点是否重新运行;无论 seed 如何,结果都是非确定性的。"
}
},
"outputs": {
@@ -5725,54 +5609,6 @@
}
}
},
"KlingVideoNode": {
"description": "使用 Kling V3 生成视频。支持文生视频和图生视频,可选分镜多提示词和音频生成。",
"display_name": "Kling 3.0 视频",
"inputs": {
"control_after_generate": {
"name": "生成后控制"
},
"generate_audio": {
"name": "生成音频"
},
"model": {
"name": "模型",
"tooltip": "模型和生成设置。"
},
"model_aspect_ratio": {
"name": "宽高比"
},
"model_resolution": {
"name": "分辨率"
},
"multi_shot": {
"name": "多镜头",
"tooltip": "使用单独的提示词和时长生成一系列视频片段。"
},
"multi_shot_duration": {
"name": "时长"
},
"multi_shot_negative_prompt": {
"name": "反向提示词"
},
"multi_shot_prompt": {
"name": "提示词"
},
"seed": {
"name": "seed",
"tooltip": "Seed 控制节点是否重新运行;无论 seed 如何,结果都是非确定性的。"
},
"start_frame": {
"name": "起始帧",
"tooltip": "可选的起始帧图像。连接后切换为图生视频模式。"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KlingVirtualTryOnNode": {
"description": "Kling 虚拟试穿节点。输入一张人物图片和一张服装图片,将服装试穿到人物身上。",
"display_name": "Kling 虚拟试穿",
@@ -15448,31 +15284,6 @@
}
}
},
"Video Slice": {
"display_name": "视频切片",
"inputs": {
"duration": {
"name": "时长",
"tooltip": "以秒为单位的时长0 表示不限时长"
},
"start_time": {
"name": "起始时间",
"tooltip": "以秒为单位的起始时间"
},
"strict_duration": {
"name": "严格时长",
"tooltip": "如为 True当指定时长无法实现时将报错。"
},
"video": {
"name": "视频"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VideoLinearCFGGuidance": {
"display_name": "视频线性CFG引导",
"inputs": {

View File

@@ -104,10 +104,6 @@
"Comfy_Graph_CtrlShiftZoom": {
"name": "启用快速缩放快捷键Ctrl + Shift + 拖动)"
},
"Comfy_Graph_DeduplicateSubgraphNodeIds": {
"name": "去重子图节点ID",
"tooltip": "在加载工作流时自动重新分配子图中重复的节点ID。"
},
"Comfy_Graph_LinkMarkers": {
"name": "连线中点标记",
"options": {

View File

@@ -53,9 +53,7 @@ describe('useWorkspaceSwitch', () => {
id: 'workspace-1',
name: 'Test Workspace',
type: 'personal',
role: 'owner',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z'
role: 'owner'
}
mockModifiedWorkflows.length = 0
})

View File

@@ -16,7 +16,7 @@ const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockTrackBeginCheckout = vi.fn()
const mockUserId = ref<string | undefined>('user-123')
const mockGetAuthHeader = vi.fn(() =>
const mockGetFirebaseAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
)
const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
@@ -58,7 +58,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () =>
reactive({
getAuthHeader: mockGetAuthHeader,
getFirebaseAuthHeader: mockGetFirebaseAuthHeader,
userId: computed(() => mockUserId.value)
}),
FirebaseAuthStoreError: class extends Error {}

View File

@@ -267,7 +267,7 @@ import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptio
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes'
@@ -280,19 +280,6 @@ const getCheckoutTier = (
billingCycle: BillingCycle
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}
const { getCheckoutAttribution } =
await import('@/platform/telemetry/utils/checkoutAttribution')
return getCheckoutAttribution()
}
interface BillingCycleOption {
label: string
value: BillingCycle
@@ -428,7 +415,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
try {
if (isActiveSubscription.value) {
const checkoutAttribution = await getCheckoutAttributionForCloud()
const checkoutAttribution = getCheckoutAttribution()
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,

View File

@@ -9,7 +9,6 @@ const {
mockAccessBillingPortal,
mockShowSubscriptionRequiredDialog,
mockGetAuthHeader,
mockGetCheckoutAttribution,
mockTelemetry,
mockUserId,
mockIsCloud
@@ -22,10 +21,6 @@ const {
mockGetAuthHeader: vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
),
mockGetCheckoutAttribution: vi.fn(() => ({
im_ref: 'impact-click-001',
utm_source: 'impact'
})),
mockTelemetry: {
trackSubscription: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn()
@@ -34,13 +29,6 @@ const {
}))
let scope: ReturnType<typeof effectScope> | undefined
type Distribution = 'desktop' | 'localhost' | 'cloud'
const setDistribution = (distribution: Distribution) => {
;(
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
).__DISTRIBUTION__ = distribution
}
function useSubscriptionWithScope() {
if (!scope) {
@@ -96,10 +84,6 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
getCheckoutAttribution: mockGetCheckoutAttribution
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
@@ -108,7 +92,7 @@ vi.mock('@/services/dialogService', () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader,
getFirebaseAuthHeader: mockGetAuthHeader,
get userId() {
return mockUserId.value
}
@@ -123,13 +107,11 @@ describe('useSubscription', () => {
afterEach(() => {
scope?.stop()
scope = undefined
setDistribution('localhost')
})
beforeEach(() => {
scope?.stop()
scope = effectScope()
setDistribution('cloud')
vi.clearAllMocks()
mockIsLoggedIn.value = false
@@ -302,10 +284,6 @@ describe('useSubscription', () => {
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
'Content-Type': 'application/json'
}),
body: JSON.stringify({
im_ref: 'impact-click-001',
utm_source: 'impact'
})
})
)
@@ -363,27 +341,6 @@ describe('useSubscription', () => {
})
})
describe('non-cloud environments', () => {
it('should not fetch subscription status when not on cloud', async () => {
mockIsCloud.value = false
mockIsLoggedIn.value = true
useSubscriptionWithScope()
await vi.dynamicImportSettled()
expect(global.fetch).not.toHaveBeenCalled()
})
it('should report isActiveSubscription as true when not on cloud', () => {
mockIsCloud.value = false
const { isActiveSubscription } = useSubscriptionWithScope()
expect(isActiveSubscription.value).toBe(true)
})
})
describe('action handlers', () => {
it('should open usage history URL', () => {
const windowOpenSpy = vi

View File

@@ -8,7 +8,6 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
@@ -40,7 +39,7 @@ function useSubscriptionInternal() {
const { showSubscriptionRequiredDialog } = useDialogService()
const firebaseAuthStore = useFirebaseAuthStore()
const { getAuthHeader } = firebaseAuthStore
const { getFirebaseAuthHeader } = firebaseAuthStore
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -99,18 +98,6 @@ function useSubscriptionInternal() {
return `${getComfyApiBaseUrl()}${path}`
}
const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}
const { getCheckoutAttribution } =
await import('@/platform/telemetry/utils/checkoutAttribution')
return getCheckoutAttribution()
}
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
@@ -184,7 +171,7 @@ function useSubscriptionInternal() {
* @returns Subscription status or null if no subscription exists
*/
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
@@ -217,7 +204,7 @@ function useSubscriptionInternal() {
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn && isCloud) {
if (loggedIn) {
try {
await fetchSubscriptionStatus()
} catch (error) {
@@ -238,13 +225,12 @@ function useSubscriptionInternal() {
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
}
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
@@ -253,8 +239,7 @@ function useSubscriptionInternal() {
headers: {
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(checkoutAttribution)
}
}
)

View File

@@ -22,10 +22,6 @@ const {
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
im_ref: 'impact-click-123',
utm_source: 'impact',
utm_medium: 'affiliate',
utm_campaign: 'spring-launch',
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
@@ -39,7 +35,7 @@ vi.mock('@/platform/telemetry', () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() =>
reactive({
getAuthHeader: mockGetAuthHeader,
getFirebaseAuthHeader: mockGetAuthHeader,
userId: computed(() => mockUserId.value)
})
),
@@ -58,14 +54,6 @@ vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
global.fetch = vi.fn()
type Distribution = 'desktop' | 'localhost' | 'cloud'
const setDistribution = (distribution: Distribution) => {
;(
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
).__DISTRIBUTION__ = distribution
}
function createDeferred<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((res) => {
@@ -77,7 +65,6 @@ function createDeferred<T>() {
describe('performSubscriptionCheckout', () => {
beforeEach(() => {
setDistribution('cloud')
vi.clearAllMocks()
mockIsCloud.value = true
mockUserId.value = 'user-123'
@@ -85,7 +72,6 @@ describe('performSubscriptionCheckout', () => {
afterEach(() => {
vi.restoreAllMocks()
setDistribution('localhost')
})
it('tracks begin_checkout with user id and tier metadata', async () => {
@@ -107,10 +93,6 @@ describe('performSubscriptionCheckout', () => {
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
im_ref: 'impact-click-123',
utm_source: 'impact',
utm_medium: 'affiliate',
utm_campaign: 'spring-launch',
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
@@ -125,10 +107,6 @@ describe('performSubscriptionCheckout', () => {
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
im_ref: 'impact-click-123',
utm_source: 'impact',
utm_medium: 'affiliate',
utm_campaign: 'spring-launch',
gclid: 'gclid-123',
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
@@ -138,41 +116,6 @@ describe('performSubscriptionCheckout', () => {
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('continues checkout when attribution collection fails', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockGetCheckoutAttribution.mockRejectedValueOnce(
new Error('Attribution failed')
)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', true)
expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
expect.any(Error)
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/customers/cloud-subscription-checkout/pro'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({})
})
)
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
})
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)

View File

@@ -4,11 +4,11 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from './subscriptionTierRank'
@@ -19,18 +19,6 @@ const getCheckoutTier = (
billingCycle: BillingCycle
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
const getCheckoutAttributionForCloud =
async (): Promise<CheckoutAttributionMetadata> => {
if (__DISTRIBUTION__ !== 'cloud') {
return {}
}
const { getCheckoutAttribution } =
await import('@/platform/telemetry/utils/checkoutAttribution')
return getCheckoutAttribution()
}
/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
@@ -54,22 +42,14 @@ export async function performSubscriptionCheckout(
const firebaseAuthStore = useFirebaseAuthStore()
const { userId } = storeToRefs(firebaseAuthStore)
const telemetry = useTelemetry()
const authHeader = await firebaseAuthStore.getAuthHeader()
const authHeader = await firebaseAuthStore.getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
let checkoutAttribution: CheckoutAttributionMetadata = {}
try {
checkoutAttribution = await getCheckoutAttributionForCloud()
} catch (error) {
console.warn(
'[SubscriptionCheckout] Failed to collect checkout attribution',
error
)
}
const checkoutAttribution = getCheckoutAttribution()
const checkoutPayload = { ...checkoutAttribution }
const response = await fetch(

View File

@@ -137,14 +137,14 @@ export function useKeybindingService() {
}
async function persistUserKeybindings() {
await settingStore.setMany({
'Comfy.Keybinding.NewBindings': Object.values(
keybindingStore.getUserKeybindings()
),
'Comfy.Keybinding.UnsetBindings': Object.values(
keybindingStore.getUserUnsetKeybindings()
)
})
await settingStore.set(
'Comfy.Keybinding.NewBindings',
Object.values(keybindingStore.getUserKeybindings())
)
await settingStore.set(
'Comfy.Keybinding.UnsetBindings',
Object.values(keybindingStore.getUserUnsetKeybindings())
)
}
return {

View File

@@ -3,9 +3,7 @@ import type { NodeReplacementResponse } from './types'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { fetchNodeReplacements } from './nodeReplacementService'
import { useNodeReplacementStore } from './nodeReplacementStore'
@@ -17,12 +15,6 @@ vi.mock('./nodeReplacementService', () => ({
fetchNodeReplacements: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
}))
function mockSettingStore(enabled: boolean) {
vi.mocked(useSettingStore, { partial: true }).mockReturnValue({
get: vi.fn().mockImplementation((key: string) => {
@@ -35,17 +27,9 @@ function mockSettingStore(enabled: boolean) {
})
}
function createStore(settingEnabled = true, serverFeatureEnabled = true) {
function createStore(enabled = true) {
setActivePinia(createPinia())
mockSettingStore(settingEnabled)
vi.mocked(api.getServerFeature).mockImplementation(
(flag: string, defaultValue?: unknown) => {
if (flag === ServerFeatureFlag.NODE_REPLACEMENTS) {
return serverFeatureEnabled
}
return defaultValue
}
)
mockSettingStore(enabled)
return useNodeReplacementStore()
}
@@ -54,7 +38,7 @@ describe('useNodeReplacementStore', () => {
beforeEach(() => {
vi.clearAllMocks()
store = createStore()
store = createStore(true)
})
it('should initialize with empty replacements', () => {
@@ -244,7 +228,7 @@ describe('useNodeReplacementStore', () => {
consoleErrorSpy.mockRestore()
})
it('should not fetch when setting is disabled', async () => {
it('should not fetch when feature is disabled', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue({})
store = createStore(false)
@@ -254,16 +238,6 @@ describe('useNodeReplacementStore', () => {
expect(store.isLoaded).toBe(false)
})
it('should not fetch when server feature flag is disabled', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
store = createStore(true, false)
await store.load()
expect(fetchNodeReplacements).not.toHaveBeenCalled()
expect(store.isLoaded).toBe(false)
})
it('should not re-fetch when called twice', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
store = createStore()

View File

@@ -3,9 +3,7 @@ import type { NodeReplacement, NodeReplacementResponse } from './types'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { fetchNodeReplacements } from './nodeReplacementService'
export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
@@ -17,9 +15,7 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
)
async function load() {
if (!isEnabled.value || isLoaded.value) return
if (!api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false))
return
if (isLoaded.value || !isEnabled.value) return
try {
replacements.value = await fetchNodeReplacements()
@@ -42,8 +38,8 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
return {
replacements,
isLoaded,
isEnabled,
load,
isEnabled,
getReplacementFor,
hasReplacement
}

View File

@@ -1,14 +1,17 @@
interface InputMapOldId {
new_id: string
interface InputAssignOldId {
assign_type: 'old_id'
old_id: string
}
interface InputMapSetValue {
new_id: string
set_value: unknown
interface InputAssignSetValue {
assign_type: 'set_value'
value: unknown
}
type InputMap = InputMapOldId | InputMapSetValue
interface InputMap {
new_id: string
assign: InputAssignOldId | InputAssignSetValue
}
interface OutputMap {
new_idx: number

View File

@@ -1,654 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeReplacement } from './types'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn(),
registered_node_types: {}
}
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: null },
sanitizeNodeName: (name: string) => name.replace(/[&<>"'`=]/g, '')
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: {
changeTracker: {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
}
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
}))
import { app } from '@/scripts/app'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useNodeReplacement } from './useNodeReplacement'
function createMockLink(
id: number,
originId: number,
originSlot: number,
targetId: number,
targetSlot: number
) {
return {
id,
origin_id: originId,
origin_slot: originSlot,
target_id: targetId,
target_slot: targetSlot,
type: 'IMAGE'
}
}
function createMockGraph(
nodes: LGraphNode[],
links: ReturnType<typeof createMockLink>[] = []
): LGraph {
const linksMap = new Map(links.map((l) => [l.id, l]))
return {
_nodes: nodes,
_nodes_by_id: Object.fromEntries(nodes.map((n) => [n.id, n])),
links: linksMap,
updateExecutionOrder: vi.fn(),
setDirtyCanvas: vi.fn()
} as unknown as LGraph
}
function createPlaceholderNode(
id: number,
type: string,
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
graph?: LGraph
): LGraphNode {
return {
id,
type,
pos: [100, 200],
size: [200, 100],
order: 0,
mode: 0,
flags: {},
has_errors: true,
last_serialization: {
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
},
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
graph: graph ?? null,
serialize: vi.fn(() => ({
id,
type,
pos: [100, 200],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets_values: []
}))
} as unknown as LGraphNode
}
function createNewNode(
inputs: { name: string; link: number | null }[] = [],
outputs: { name: string; links: number[] | null }[] = [],
widgets: { name: string; value: unknown }[] = []
): LGraphNode {
return {
id: 0,
type: '',
pos: [0, 0],
size: [100, 50],
order: 0,
mode: 0,
flags: {},
has_errors: false,
inputs: inputs.map((i) => ({ ...i, type: 'IMAGE' })),
outputs: outputs.map((o) => ({ ...o, type: 'IMAGE' })),
widgets: widgets.map((w) => ({ ...w, type: 'combo', options: {} })),
configure: vi.fn(),
serialize: vi.fn()
} as unknown as LGraphNode
}
function makeMissingNodeType(
type: string,
replacement: NodeReplacement
): MissingNodeType {
return {
type,
isReplaceable: true,
replacement
}
}
describe('useNodeReplacement', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
})
describe('replaceNodesInPlace', () => {
it('should return empty array when no placeholders exist', () => {
const graph = createMockGraph([])
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([])
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([])
expect(result).toEqual([])
})
it('should use default mapping when no explicit mapping exists', () => {
const placeholder = createPlaceholderNode(1, 'Load3DAnimation')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual(['Load3DAnimation'])
expect(newNode.configure).not.toHaveBeenCalled()
expect(newNode.id).toBe(1)
expect(newNode.has_errors).toBe(false)
})
it('should transfer input connections using input_mapping', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
1,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(result).toEqual(['T2IAdapterLoader'])
// Link should be updated to point at new node's input
expect(link.target_id).toBe(1)
expect(link.target_slot).toBe(0)
expect(newNode.inputs[0].link).toBe(10)
})
it('should transfer output connections using output_mapping', () => {
const link = createMockLink(20, 1, 0, 5, 0)
const placeholder = createPlaceholderNode(
1,
'ResizeImagesByLongerEdge',
[],
[{ name: 'IMAGE', links: [20] }]
)
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'image', link: null }],
[{ name: 'IMAGE', links: null }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Output link should be remapped
expect(link.origin_id).toBe(1)
expect(link.origin_slot).toBe(0)
expect(newNode.outputs[0].links).toEqual([20])
})
it('should apply set_value to widget', () => {
const placeholder = createPlaceholderNode(1, 'ImageScaleBy')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied to the widget
expect(newNode.widgets![0].value).toBe('scale by multiplier')
})
it('should transfer widget values using old_widget_ids', () => {
const placeholder = createPlaceholderNode(1, 'ResizeImagesByLongerEdge')
// Set widget values in serialized data
placeholder.last_serialization!.widgets_values = [512]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[{ name: 'largest_size', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
expect(newNode.widgets![0].value).toBe(512)
})
it('should skip replacement when new node type is not registered', () => {
const placeholder = createPlaceholderNode(1, 'UnknownNode')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('UnknownNode', {
new_node_id: 'NonExistentNode',
old_node_id: 'UnknownNode',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toEqual([])
})
it('should replace multiple different node types at once', () => {
const placeholder1 = createPlaceholderNode(1, 'Load3DAnimation')
const placeholder2 = createPlaceholderNode(
2,
'ConditioningAverage',
[],
[]
)
// sanitizeNodeName strips & from type names (HTML entity chars)
placeholder2.type = 'ConditioningAverage'
const graph = createMockGraph([placeholder1, placeholder2])
placeholder1.graph = graph
placeholder2.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder1, placeholder2])
const newNode1 = createNewNode()
const newNode2 = createNewNode()
vi.mocked(LiteGraph.createNode)
.mockReturnValueOnce(newNode1)
.mockReturnValueOnce(newNode2)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('Load3DAnimation', {
new_node_id: 'Load3D',
old_node_id: 'Load3DAnimation',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}),
makeMissingNodeType('ConditioningAverage&', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage&',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
expect(result).toHaveLength(2)
expect(result).toContain('Load3DAnimation')
expect(result).toContain('ConditioningAverage&')
})
it('should copy position and identity for mapped replacements', () => {
const link = createMockLink(10, 5, 0, 1, 0)
const placeholder = createPlaceholderNode(
42,
'T2IAdapterLoader',
[{ name: 't2i_adapter_name', link: 10 }],
[]
)
placeholder.pos = [300, 400]
placeholder.size = [250, 150]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'control_net_name', link: null }],
[]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('T2IAdapterLoader', {
new_node_id: 'ControlNetLoader',
old_node_id: 'T2IAdapterLoader',
old_widget_ids: null,
input_mapping: [
{ new_id: 'control_net_name', old_id: 't2i_adapter_name' }
],
output_mapping: null
})
])
expect(newNode.id).toBe(42)
expect(newNode.pos).toEqual([300, 400])
expect(newNode.size).toEqual([250, 150])
expect(graph._nodes[0]).toBe(newNode)
})
it('should transfer all widget values for ImageScaleBy with real workflow data', () => {
const placeholder = createPlaceholderNode(
12,
'ImageScaleBy',
[{ name: 'image', link: 2 }],
[{ name: 'IMAGE', links: [3, 4] }]
)
// Real workflow data: widgets_values: ["lanczos", 2.0]
placeholder.last_serialization!.widgets_values = ['lanczos', 2.0]
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[{ name: 'input', link: null }],
[],
[
{ name: 'resize_type', value: '' },
{ name: 'scale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ImageScaleBy', {
new_node_id: 'ResizeImageMaskNode',
old_node_id: 'ImageScaleBy',
old_widget_ids: ['upscale_method', 'scale_by'],
input_mapping: [
{ new_id: 'input', old_id: 'image' },
{ new_id: 'resize_type', set_value: 'scale by multiplier' },
{ new_id: 'resize_type.multiplier', old_id: 'scale_by' },
{ new_id: 'scale_method', old_id: 'upscale_method' }
],
output_mapping: null
})
])
// set_value should be applied
expect(newNode.widgets![0].value).toBe('scale by multiplier')
// upscale_method (idx 0, value "lanczos") → scale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer widget value for ResizeImagesByLongerEdge with real workflow data', () => {
const link = createMockLink(1, 5, 0, 8, 0)
const placeholder = createPlaceholderNode(
8,
'ResizeImagesByLongerEdge',
[{ name: 'images', link: 1 }],
[{ name: 'IMAGE', links: [2] }]
)
// Real workflow data: widgets_values: [1024]
placeholder.last_serialization!.widgets_values = [1024]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'image', link: null },
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[
{ name: 'largest_size', value: 0 },
{ name: 'upscale_method', value: '' }
]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ResizeImagesByLongerEdge', {
new_node_id: 'ImageScaleToMaxDimension',
old_node_id: 'ResizeImagesByLongerEdge',
old_widget_ids: ['longer_edge'],
input_mapping: [
{ new_id: 'image', old_id: 'images' },
{ new_id: 'largest_size', old_id: 'longer_edge' },
{ new_id: 'upscale_method', set_value: 'lanczos' }
],
output_mapping: [{ new_idx: 0, old_idx: 0 }]
})
])
// longer_edge (idx 0, value 1024) → largest_size widget
expect(newNode.widgets![0].value).toBe(1024)
// set_value "lanczos" → upscale_method widget
expect(newNode.widgets![1].value).toBe('lanczos')
})
it('should transfer ConditioningAverage widget value with real workflow data', () => {
const link = createMockLink(4, 7, 0, 13, 0)
// sanitizeNodeName doesn't strip spaces, so placeholder keeps trailing space
const placeholder = createPlaceholderNode(
13,
'ConditioningAverage ',
[
{ name: 'conditioning_to', link: 4 },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: [6] }]
)
placeholder.last_serialization!.widgets_values = [0.75]
const graph = createMockGraph([placeholder], [link])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode(
[
{ name: 'conditioning_to', link: null },
{ name: 'conditioning_from', link: null }
],
[{ name: 'CONDITIONING', links: null }],
[{ name: 'conditioning_average', value: 0 }]
)
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
replaceNodesInPlace([
makeMissingNodeType('ConditioningAverage ', {
new_node_id: 'ConditioningAverage',
old_node_id: 'ConditioningAverage ',
old_widget_ids: null,
input_mapping: null,
output_mapping: null
})
])
// Default mapping transfers connections and widget values by name
expect(newNode.id).toBe(13)
expect(newNode.inputs[0].link).toBe(4)
expect(newNode.outputs[0].links).toEqual([6])
expect(newNode.widgets![0].value).toBe(0.75)
})
it('should skip dot-notation input connections but still transfer widget values', () => {
const placeholder = createPlaceholderNode(1, 'ImageBatch')
const graph = createMockGraph([placeholder])
placeholder.graph = graph
Object.assign(app, { rootGraph: graph })
vi.mocked(collectAllNodes).mockReturnValue([placeholder])
const newNode = createNewNode([], [])
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
const result = replaceNodesInPlace([
makeMissingNodeType('ImageBatch', {
new_node_id: 'BatchImagesNode',
old_node_id: 'ImageBatch',
old_widget_ids: null,
input_mapping: [
{ new_id: 'images.image0', old_id: 'image1' },
{ new_id: 'images.image1', old_id: 'image2' }
],
output_mapping: null
})
])
// Should still succeed (dot-notation skipped gracefully)
expect(result).toEqual(['ImageBatch'])
})
})
})

View File

@@ -1,292 +0,0 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { t } from '@/i18n'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app, sanitizeNodeName } from '@/scripts/app'
import type { MissingNodeType } from '@/types/comfy'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
/** Compares sanitized type strings to match placeholder → missing node type. */
function findMatchingType(
node: LGraphNode,
selectedTypes: MissingNodeType[]
): Extract<MissingNodeType, { type: string }> | undefined {
const nodeType = node.type
for (const selected of selectedTypes) {
if (typeof selected !== 'object' || !selected.isReplaceable) continue
if (sanitizeNodeName(selected.type) === nodeType) return selected
}
return undefined
}
function transferInputConnection(
oldNode: LGraphNode,
oldInputName: string,
newNode: LGraphNode,
newInputName: string,
graph: LGraph
): void {
const oldSlotIdx = oldNode.inputs?.findIndex((i) => i.name === oldInputName)
const newSlotIdx = newNode.inputs?.findIndex((i) => i.name === newInputName)
if (oldSlotIdx == null || oldSlotIdx === -1) return
if (newSlotIdx == null || newSlotIdx === -1) return
const linkId = oldNode.inputs[oldSlotIdx].link
if (linkId == null) return
const link = graph.links.get(linkId)
if (!link) return
link.target_id = newNode.id
link.target_slot = newSlotIdx
newNode.inputs[newSlotIdx].link = linkId
oldNode.inputs[oldSlotIdx].link = null
}
function transferOutputConnections(
oldNode: LGraphNode,
oldOutputIdx: number,
newNode: LGraphNode,
newOutputIdx: number,
graph: LGraph
): void {
const oldLinks = oldNode.outputs?.[oldOutputIdx]?.links
if (!oldLinks?.length) return
if (!newNode.outputs?.[newOutputIdx]) return
for (const linkId of oldLinks) {
const link = graph.links.get(linkId)
if (!link) continue
link.origin_id = newNode.id
link.origin_slot = newOutputIdx
}
newNode.outputs[newOutputIdx].links = [...oldLinks]
oldNode.outputs[oldOutputIdx].links = []
}
/** Uses old_widget_ids as name→index lookup into widgets_values. */
function transferWidgetValue(
serialized: ISerialisedNode,
oldWidgetIds: string[] | null,
oldInputName: string,
newNode: LGraphNode,
newInputName: string
): void {
if (!oldWidgetIds || !serialized.widgets_values) return
const oldWidgetIdx = oldWidgetIds.indexOf(oldInputName)
if (oldWidgetIdx === -1) return
const oldValue = serialized.widgets_values[oldWidgetIdx]
if (oldValue === undefined) return
const newWidget = newNode.widgets?.find((w) => w.name === newInputName)
if (newWidget) {
newWidget.value = oldValue
newWidget.callback?.(oldValue)
}
}
function applySetValue(
newNode: LGraphNode,
inputName: string,
value: unknown
): void {
const widget = newNode.widgets?.find((w) => w.name === inputName)
if (widget) {
widget.value = value as TWidgetValue
widget.callback?.(widget.value)
}
}
function isDotNotation(id: string): boolean {
return id.includes('.')
}
/** Auto-generates identity mapping by name for same-structure replacements without backend mapping. */
function generateDefaultMapping(
serialized: ISerialisedNode,
newNode: LGraphNode
): Pick<
NodeReplacement,
'input_mapping' | 'output_mapping' | 'old_widget_ids'
> {
const oldInputNames = new Set(serialized.inputs?.map((i) => i.name) ?? [])
const inputMapping: { old_id: string; new_id: string }[] = []
for (const newInput of newNode.inputs ?? []) {
if (oldInputNames.has(newInput.name)) {
inputMapping.push({ old_id: newInput.name, new_id: newInput.name })
}
}
const oldWidgetIds = (newNode.widgets ?? []).map((w) => w.name)
for (const widget of newNode.widgets ?? []) {
if (!oldInputNames.has(widget.name)) {
inputMapping.push({ old_id: widget.name, new_id: widget.name })
}
}
const outputMapping: { old_idx: number; new_idx: number }[] = []
for (const [oldIdx, oldOutput] of (serialized.outputs ?? []).entries()) {
const newIdx = newNode.outputs?.findIndex((o) => o.name === oldOutput.name)
if (newIdx != null && newIdx !== -1) {
outputMapping.push({ old_idx: oldIdx, new_idx: newIdx })
}
}
return {
input_mapping: inputMapping.length > 0 ? inputMapping : null,
output_mapping: outputMapping.length > 0 ? outputMapping : null,
old_widget_ids: oldWidgetIds.length > 0 ? oldWidgetIds : null
}
}
function replaceWithMapping(
node: LGraphNode,
newNode: LGraphNode,
replacement: NodeReplacement,
nodeGraph: LGraph,
idx: number
): void {
newNode.id = node.id
newNode.pos = [...node.pos]
newNode.size = [...node.size]
newNode.order = node.order
newNode.mode = node.mode
if (node.flags) newNode.flags = { ...node.flags }
nodeGraph._nodes[idx] = newNode
newNode.graph = nodeGraph
nodeGraph._nodes_by_id[newNode.id] = newNode
const serialized = node.last_serialization ?? node.serialize()
if (serialized.title != null) newNode.title = serialized.title
if (serialized.properties) {
newNode.properties = { ...serialized.properties }
if ('Node name for S&R' in newNode.properties) {
newNode.properties['Node name for S&R'] = replacement.new_node_id
}
}
if (replacement.input_mapping) {
for (const inputMap of replacement.input_mapping) {
if ('old_id' in inputMap) {
if (isDotNotation(inputMap.new_id)) continue // Autogrow/DynamicCombo
transferInputConnection(
node,
inputMap.old_id,
newNode,
inputMap.new_id,
nodeGraph
)
transferWidgetValue(
serialized,
replacement.old_widget_ids,
inputMap.old_id,
newNode,
inputMap.new_id
)
} else {
if (!isDotNotation(inputMap.new_id)) {
applySetValue(newNode, inputMap.new_id, inputMap.set_value)
}
}
}
}
if (replacement.output_mapping) {
for (const outMap of replacement.output_mapping) {
transferOutputConnections(
node,
outMap.old_idx,
newNode,
outMap.new_idx,
nodeGraph
)
}
}
newNode.has_errors = false
}
export function useNodeReplacement() {
const toastStore = useToastStore()
function replaceNodesInPlace(selectedTypes: MissingNodeType[]): string[] {
const replacedTypes: string[] = []
const graph = app.rootGraph
const changeTracker =
useWorkflowStore().activeWorkflow?.changeTracker ?? null
changeTracker?.beforeChange()
try {
const placeholders = collectAllNodes(
graph,
(n) => !!n.has_errors && !!n.last_serialization
)
for (const node of placeholders) {
const match = findMatchingType(node, selectedTypes)
if (!match?.replacement) continue
const replacement = match.replacement
const nodeGraph = node.graph
if (!nodeGraph) continue
const idx = nodeGraph._nodes.indexOf(node)
if (idx === -1) continue
const newNode = LiteGraph.createNode(replacement.new_node_id)
if (!newNode) continue
const hasMapping =
replacement.input_mapping != null ||
replacement.output_mapping != null
const effectiveReplacement = hasMapping
? replacement
: {
...replacement,
...generateDefaultMapping(
node.last_serialization ?? node.serialize(),
newNode
)
}
replaceWithMapping(node, newNode, effectiveReplacement, nodeGraph, idx)
if (!replacedTypes.includes(match.type)) {
replacedTypes.push(match.type)
}
}
if (replacedTypes.length > 0) {
graph.updateExecutionOrder()
graph.setDirtyCanvas(true, true)
toastStore.add({
severity: 'success',
summary: t('g.success'),
detail: t('nodeReplacement.replacedAllNodes', {
count: replacedTypes.length
}),
life: 3000
})
}
} finally {
changeTracker?.afterChange()
}
return replacedTypes
}
return {
replaceNodesInPlace
}
}

View File

@@ -112,7 +112,7 @@ import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, nextTick, onBeforeUnmount, watch } from 'vue'
import { computed, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
@@ -129,7 +129,7 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { flattenTree } from '@/utils/treeUtil'
const { defaultPanel, scrollToSettingId } = defineProps<{
const { defaultPanel } = defineProps<{
defaultPanel?:
| 'about'
| 'keybinding'
@@ -140,7 +140,6 @@ const { defaultPanel, scrollToSettingId } = defineProps<{
| 'subscription'
| 'workspace'
| 'secrets'
scrollToSettingId?: string
}>()
const { flags } = useFeatureFlags()
@@ -154,7 +153,7 @@ const {
settingCategories,
groupedMenuTreeNodes,
panels
} = useSettingUI(defaultPanel, scrollToSettingId)
} = useSettingUI(defaultPanel)
const {
searchQuery,
@@ -203,31 +202,6 @@ const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : (activeCategory.value?.label ?? '')
)
// Scroll to and highlight the target setting once the correct tab renders.
if (scrollToSettingId) {
const stopScrollWatch = watch(
tabValue,
() => {
void nextTick(() => {
const el = document.querySelector(
`[data-setting-id="${CSS.escape(scrollToSettingId)}"]`
)
if (!el) return
stopScrollWatch()
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('setting-highlight')
el.addEventListener(
'animationend',
() => el.classList.remove('setting-highlight'),
{ once: true }
)
})
},
{ immediate: true }
)
onBeforeUnmount(stopScrollWatch)
}
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.
watch(activeCategory, (_, oldValue) => {
@@ -244,26 +218,6 @@ watch(activeCategory, (_, oldValue) => {
.settings-tab-panels {
padding-top: 0 !important;
}
@media (prefers-reduced-motion: no-preference) {
.setting-highlight {
animation: setting-highlight-pulse 1.5s ease-in-out;
}
}
@keyframes setting-highlight-pulse {
0%,
100% {
background-color: transparent;
}
30% {
background-color: color-mix(
in srgb,
var(--p-primary-color) 15%,
transparent
);
}
}
</style>
<style scoped>

View File

@@ -9,7 +9,6 @@
<div
v-for="setting in group.settings.filter((s) => !s.deprecated)"
:key="setting.id"
:data-setting-id="setting.id"
class="setting-item mb-4"
>
<SettingItem :setting="setting" />

View File

@@ -1,140 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
getSettingInfo,
useSettingStore
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: ref(false) })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isActiveSubscription: ref(false) })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
})
}))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(),
getSettingInfo: vi.fn()
}))
interface MockSettingParams {
id: string
name: string
type: string
defaultValue: unknown
category?: string[]
}
describe('useSettingUI', () => {
const mockSettings: Record<string, MockSettingParams> = {
'Comfy.Locale': {
id: 'Comfy.Locale',
name: 'Locale',
type: 'combo',
defaultValue: 'en'
},
'LiteGraph.Zoom': {
id: 'LiteGraph.Zoom',
name: 'Zoom',
type: 'slider',
defaultValue: 1
},
'Appearance.Theme': {
id: 'Appearance.Theme',
name: 'Theme',
type: 'combo',
defaultValue: 'dark'
}
}
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
vi.mocked(useSettingStore).mockReturnValue({
settingsById: mockSettings
} as ReturnType<typeof useSettingStore>)
vi.mocked(getSettingInfo).mockImplementation((setting) => {
const parts = setting.category || setting.id.split('.')
return {
category: parts[0] ?? 'Other',
subCategory: parts[1] ?? 'Other'
}
})
})
function findCategory(
categories: SettingTreeNode[],
label: string
): SettingTreeNode | undefined {
return categories.find((c) => c.label === label)
}
it('defaults to first category when no params are given', () => {
const { defaultCategory, settingCategories } = useSettingUI()
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('resolves category from scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
undefined,
'Comfy.Locale'
)
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
expect(comfyCategory).toBeDefined()
expect(defaultCategory.value).toBe(comfyCategory)
})
it('resolves different category from scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
undefined,
'Appearance.Theme'
)
const appearanceCategory = findCategory(
settingCategories.value,
'Appearance'
)
expect(appearanceCategory).toBeDefined()
expect(defaultCategory.value).toBe(appearanceCategory)
})
it('falls back to first category for unknown scrollToSettingId', () => {
const { defaultCategory, settingCategories } = useSettingUI(
undefined,
'NonExistent.Setting'
)
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('gives defaultPanel precedence over scrollToSettingId', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
})

View File

@@ -7,12 +7,10 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import {
getSettingInfo,
useSettingStore
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -32,8 +30,7 @@ export function useSettingUI(
| 'credits'
| 'subscription'
| 'workspace'
| 'secrets',
scrollToSettingId?: string
| 'secrets'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
@@ -241,23 +238,12 @@ export function useSettingUI(
* The default category to show when the dialog is opened.
*/
const defaultCategory = computed<SettingTreeNode>(() => {
if (defaultPanel) {
for (const group of groupedMenuTreeNodes.value) {
const found = group.children?.find((node) => node.key === defaultPanel)
if (found) return found
}
return settingCategories.value[0]
if (!defaultPanel) return settingCategories.value[0]
// Search through all groups in groupedMenuTreeNodes
for (const group of groupedMenuTreeNodes.value) {
const found = group.children?.find((node) => node.key === defaultPanel)
if (found) return found
}
if (scrollToSettingId) {
const setting = settingStore.settingsById[scrollToSettingId]
if (setting) {
const { category } = getSettingInfo(setting)
const found = settingCategories.value.find((c) => c.label === category)
if (found) return found
}
}
return settingCategories.value[0]
})

View File

@@ -170,15 +170,13 @@ export const CORE_SETTINGS: SettingParams[] = [
const settingStore = useSettingStore()
if (newValue === 'standard') {
await settingStore.setMany({
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
'Comfy.Canvas.MouseWheelScroll': 'panning'
})
// Update related settings to match standard mode - select + panning
await settingStore.set('Comfy.Canvas.LeftMouseClickBehavior', 'select')
await settingStore.set('Comfy.Canvas.MouseWheelScroll', 'panning')
} else if (newValue === 'legacy') {
await settingStore.setMany({
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
'Comfy.Canvas.MouseWheelScroll': 'zoom'
})
// Update related settings to match legacy mode - panning + zoom
await settingStore.set('Comfy.Canvas.LeftMouseClickBehavior', 'panning')
await settingStore.set('Comfy.Canvas.MouseWheelScroll', 'zoom')
}
}
},
@@ -1196,9 +1194,9 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.NodeReplacement.Enabled',
category: ['Comfy', 'Workflow', 'NodeReplacement'],
name: 'Enable node replacement suggestions',
name: 'Enable automatic node replacement',
tooltip:
'When enabled, missing nodes with known replacements will be shown as replaceable in the missing nodes dialog, allowing you to review and apply replacements.',
'When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists.',
type: 'boolean',
defaultValue: false,
experimental: true,

View File

@@ -15,8 +15,7 @@ import { app } from '@/scripts/app'
vi.mock('@/scripts/api', () => ({
api: {
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
storeSetting: vi.fn()
}
}))
@@ -504,85 +503,6 @@ describe('useSettingStore', () => {
})
})
})
describe('setMany', () => {
it('should set multiple values and make a single API call', async () => {
const onChange1 = vi.fn()
const onChange2 = vi.fn()
store.addSetting({
id: 'Comfy.Release.Version',
name: 'Release Version',
type: 'hidden',
defaultValue: '',
onChange: onChange1
})
store.addSetting({
id: 'Comfy.Release.Status',
name: 'Release Status',
type: 'hidden',
defaultValue: 'skipped',
onChange: onChange2
})
vi.clearAllMocks()
await store.setMany({
'Comfy.Release.Version': '1.0.0',
'Comfy.Release.Status': 'changelog seen'
})
expect(store.get('Comfy.Release.Version')).toBe('1.0.0')
expect(store.get('Comfy.Release.Status')).toBe('changelog seen')
expect(onChange1).toHaveBeenCalledWith('1.0.0', '')
expect(onChange2).toHaveBeenCalledWith('changelog seen', 'skipped')
expect(api.storeSettings).toHaveBeenCalledTimes(1)
expect(api.storeSettings).toHaveBeenCalledWith({
'Comfy.Release.Version': '1.0.0',
'Comfy.Release.Status': 'changelog seen'
})
expect(api.storeSetting).not.toHaveBeenCalled()
})
it('should skip unchanged values', async () => {
store.addSetting({
id: 'Comfy.Release.Version',
name: 'Release Version',
type: 'hidden',
defaultValue: ''
})
store.addSetting({
id: 'Comfy.Release.Status',
name: 'Release Status',
type: 'hidden',
defaultValue: 'skipped'
})
await store.set('Comfy.Release.Version', 'existing')
vi.clearAllMocks()
await store.setMany({
'Comfy.Release.Version': 'existing',
'Comfy.Release.Status': 'changelog seen'
})
expect(api.storeSettings).toHaveBeenCalledWith({
'Comfy.Release.Status': 'changelog seen'
})
})
it('should not call API when all values are unchanged', async () => {
store.addSetting({
id: 'Comfy.Release.Version',
name: 'Release Version',
type: 'hidden',
defaultValue: ''
})
await store.set('Comfy.Release.Version', 'existing')
vi.clearAllMocks()
await store.setMany({ 'Comfy.Release.Version': 'existing' })
expect(api.storeSettings).not.toHaveBeenCalled()
})
})
})
describe('getSettingInfo', () => {

View File

@@ -92,58 +92,23 @@ export const useSettingStore = defineStore('setting', () => {
}
/**
* Apply a setting value locally: clone, migrate, fire onChange, and
* update the in-memory store. Returns the migrated value, or
* `undefined` when the value is unchanged and was skipped.
* Set a setting value.
* @param key - The key of the setting to set.
* @param value - The value to set.
*/
function applySettingLocally<K extends keyof Settings>(
key: K,
value: Settings[K]
): Settings[K] | undefined {
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
// Clone the incoming value to prevent external mutations
const clonedValue = _.cloneDeep(value)
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
clonedValue
)
const oldValue = get(key)
if (newValue === oldValue) return undefined
if (newValue === oldValue) return
onChange(settingsById.value[key], newValue, oldValue)
settingValues.value[key] = newValue
return newValue as Settings[K]
}
/**
* Set a setting value.
* @param key - The key of the setting to set.
* @param value - The value to set.
*/
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
const applied = applySettingLocally(key, value)
if (applied === undefined) return
await api.storeSetting(key, applied)
}
/**
* Set multiple setting values in a single API call.
* @param settings - A partial settings object with key-value pairs to set.
*/
async function setMany(settings: Partial<Settings>) {
const updatedSettings: Partial<Settings> = {}
for (const key of Object.keys(settings) as (keyof Settings)[]) {
const applied = applySettingLocally(
key,
settings[key] as Settings[typeof key]
)
if (applied !== undefined) {
updatedSettings[key] = applied
}
}
if (Object.keys(updatedSettings).length > 0) {
await api.storeSettings(updatedSettings)
}
await api.storeSetting(key, newValue)
}
/**
@@ -306,7 +271,6 @@ export const useSettingStore = defineStore('setting', () => {
load,
addSetting,
set,
setMany,
get,
exists,
getDefaultValue

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