mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
7 Commits
ext-api/i-
...
docs/weekl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a0ed7683 | ||
|
|
fac3a8c2bd | ||
|
|
15b8771cc2 | ||
|
|
e68d50e677 | ||
|
|
48b5e0165a | ||
|
|
fe1de3b254 | ||
|
|
1c2ae70343 |
75
AGENTS.md
75
AGENTS.md
@@ -26,16 +26,33 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
|
||||
- Public assets: `public/`
|
||||
- Build output: `dist/`
|
||||
- Configs
|
||||
- `vite.config.mts`
|
||||
- `playwright.config.ts`
|
||||
- `eslint.config.ts`
|
||||
- `.oxfmtrc.json`
|
||||
- `.oxlintrc.json`
|
||||
- etc.
|
||||
- `vite.config.mts` - Main build config
|
||||
- `vite.electron.config.mts` - Electron dev server config
|
||||
- `vite.types.config.mts` - Type definitions build config
|
||||
- `playwright.config.ts` - E2E test config
|
||||
- `playwright.i18n.config.ts` - i18n collection test config
|
||||
- `eslint.config.ts` - ESLint configuration
|
||||
- `.oxfmtrc.json` - Formatter settings
|
||||
- `.oxlintrc.json` - Linter settings
|
||||
- `tsconfig.json` - TypeScript configuration (multiple per package)
|
||||
|
||||
## Monorepo Architecture
|
||||
|
||||
The project uses **Nx** for build orchestration and task management
|
||||
The project uses **Nx** for build orchestration and task management.
|
||||
|
||||
### Structure
|
||||
|
||||
- **Apps:**
|
||||
- `apps/desktop-ui/` - Desktop application (Electron)
|
||||
- `apps/website/` - Marketing/documentation website
|
||||
- **Packages:**
|
||||
- `packages/design-system/` - Shared design tokens and components
|
||||
- `packages/ingest-types/` - Auto-generated types from cloud API OpenAPI spec
|
||||
- `packages/registry-types/` - Auto-generated types from registry API OpenAPI spec
|
||||
- `packages/shared-frontend-utils/` - Common utilities
|
||||
- `packages/tailwind-utils/` - Tailwind CSS utilities
|
||||
|
||||
Each app and package has its own `tsconfig.json`, `vitest.config.ts`, and build configuration.
|
||||
|
||||
## Package Manager
|
||||
|
||||
@@ -43,17 +60,55 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm dev`: Start Vite dev server.
|
||||
### Development
|
||||
|
||||
- `pnpm dev`: Start Vite dev server
|
||||
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
|
||||
- `pnpm dev:electron`: Dev server with Electron API mocks
|
||||
- `pnpm dev:desktop`: Desktop UI dev server
|
||||
- `pnpm dev:no-vue`: Dev server with Vue plugins disabled
|
||||
|
||||
### Build
|
||||
|
||||
- `pnpm build`: Type-check then production build to `dist/`
|
||||
- `pnpm build:cloud`: Production build with cloud distribution
|
||||
- `pnpm build:desktop`: Build desktop UI variant
|
||||
- `pnpm build:types`: Generate type definitions library
|
||||
- `pnpm build:analyze`: Production build with bundle analyzer
|
||||
- `pnpm preview`: Preview the production build locally
|
||||
|
||||
### Testing
|
||||
|
||||
- `pnpm test:unit`: Run Vitest unit tests
|
||||
- `pnpm test:browser:local`: Run Playwright E2E tests (`browser_tests/`)
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||
- `pnpm test:coverage`: Run unit tests with coverage report
|
||||
|
||||
### Code Quality
|
||||
|
||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint + oxlint)
|
||||
- `pnpm lint:desktop`: Lint desktop app
|
||||
- `pnpm format` / `pnpm format:check`: oxfmt
|
||||
- `pnpm typecheck`: Vue TSC type checking
|
||||
- `pnpm typecheck:browser`: Type-check browser tests
|
||||
- `pnpm typecheck:desktop`: Type-check desktop UI
|
||||
- `pnpm knip`: Check for unused exports and dependencies
|
||||
|
||||
### Storybook
|
||||
|
||||
- `pnpm storybook`: Start Storybook development server
|
||||
- `pnpm storybook:desktop`: Start Storybook for desktop UI
|
||||
- `pnpm build-storybook`: Build static Storybook
|
||||
|
||||
### Internationalization
|
||||
|
||||
- `pnpm collect-i18n`: Collect i18n strings using Playwright
|
||||
- `pnpm locale`: Lobe i18n CLI command
|
||||
|
||||
### Analysis & Utilities
|
||||
|
||||
- `pnpm size:collect` / `pnpm size:report`: Bundle size analysis
|
||||
- `pnpm json-schema`: Generate JSON schema from TypeScript types
|
||||
- `pnpm zipdist`: Create distribution zip file
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -246,6 +301,8 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
|
||||
|
||||
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
|
||||
|
||||
**Note:** ADR 0003 (CRDT-based layout) and ADR 0008 (Entity Component System) are currently marked as "Proposed" but represent the target architecture. The codebase is in active migration from the legacy OOP patterns to these new patterns. New code should follow these constraints; legacy code will be refactored incrementally.
|
||||
|
||||
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
|
||||
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
|
||||
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
|
||||
|
||||
79
README.md
79
README.md
@@ -533,6 +533,85 @@ The selection toolbox will display the command button when items are selected:
|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-topbar-badges'>
|
||||
<summary>Topbar Badges API</summary>
|
||||
|
||||
Extensions can add status badges to the top bar with different variants and tooltips.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
topbarBadges: [
|
||||
{
|
||||
text: 'Nightly',
|
||||
label: 'BETA',
|
||||
variant: 'warning', // 'info' | 'warning' | 'error'
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
tooltip: 'You are using a nightly build'
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
Variants:
|
||||
|
||||
- `info`: Default informational badge (white label, gray background)
|
||||
- `warning`: Warning badge (orange theme, higher emphasis)
|
||||
- `error`: Error/alert badge (red theme, highest emphasis)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-action-bar-buttons'>
|
||||
<summary>Action Bar Buttons API</summary>
|
||||
|
||||
Extensions can add clickable buttons to the action bar with icons, labels, and tooltips.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: 'icon-[lucide--message-circle-question-mark]',
|
||||
label: 'Feedback',
|
||||
tooltip: 'Send feedback about ComfyUI',
|
||||
class: 'custom-button-class', // Optional CSS classes
|
||||
onClick: () => {
|
||||
// Button click handler
|
||||
alert('Feedback clicked!')
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
The buttons appear in the action bar alongside other controls.
|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-markdown-renderer'>
|
||||
<summary>Markdown Rendering API</summary>
|
||||
|
||||
Extensions can render markdown to sanitized HTML using the built-in markdown renderer.
|
||||
|
||||
```js
|
||||
// Render markdown with GitHub Flavored Markdown support
|
||||
const html = app.extensionManager.renderMarkdownToHtml(
|
||||
'# Hello\n\nThis is **bold** text',
|
||||
'https://example.com' // Optional base URL for relative links
|
||||
)
|
||||
|
||||
// The output is sanitized with DOMPurify for XSS protection
|
||||
document.getElementById('content').innerHTML = html
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
- GitHub Flavored Markdown (GFM) support via marked
|
||||
- Automatic XSS sanitization via DOMPurify
|
||||
- Optional base URL for resolving relative links
|
||||
|
||||
</details>
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions to ComfyUI Frontend! Please see our [Contributing Guide](CONTRIBUTING.md) for:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
NodeError,
|
||||
NodeProgressState,
|
||||
PromptResponse
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
@@ -230,6 +234,16 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress_state` WS event with per-node execution state. */
|
||||
progressState(jobId: string, nodes: Record<string, NodeProgressState>): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'progress_state',
|
||||
data: { prompt_id: jobId, nodes }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a job by adding it to mock history, sending execution_success,
|
||||
* and triggering a history refresh via a status event.
|
||||
|
||||
211
browser_tests/tests/wsReconnectStaleJob.spec.ts
Normal file
211
browser_tests/tests/wsReconnectStaleJob.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type {
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const KSAMPLER_NODE = '3'
|
||||
const EXECUTING_CLASS = /outline-node-stroke-executing/
|
||||
|
||||
const QUEUE_ROUTE = /\/api\/jobs\?[^/]*status=in_progress,pending/
|
||||
const HISTORY_ROUTE = /\/api\/jobs\?[^/]*status=completed/
|
||||
|
||||
function jobsResponse(jobs: RawJobListItem[]): JobsListResponse {
|
||||
return {
|
||||
jobs,
|
||||
pagination: { offset: 0, limit: 200, total: jobs.length, has_more: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function mockJobsRoute(
|
||||
comfyPage: ComfyPage,
|
||||
pattern: RegExp,
|
||||
body: string,
|
||||
status: number = 200
|
||||
): Promise<() => number> {
|
||||
let count = 0
|
||||
await comfyPage.page.route(pattern, async (route) => {
|
||||
count += 1
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body
|
||||
})
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
const emptyJobsBody = JSON.stringify(jobsResponse([]))
|
||||
|
||||
type Scenario = {
|
||||
name: string
|
||||
/** Built per-test so it can incorporate the runtime-assigned jobId. */
|
||||
queueBody: (jobId: string) => string
|
||||
/** Whether the active job state should still be reflected after reconnect. */
|
||||
expectsActiveAfter: boolean
|
||||
}
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: 'clears stale active job when queue is empty after reconnect',
|
||||
queueBody: () => emptyJobsBody,
|
||||
expectsActiveAfter: false
|
||||
},
|
||||
{
|
||||
name: 'preserves active job when the job is still in the queue',
|
||||
queueBody: (jobId) =>
|
||||
JSON.stringify(
|
||||
jobsResponse([
|
||||
{ id: jobId, status: 'in_progress', create_time: Date.now() }
|
||||
])
|
||||
),
|
||||
expectsActiveAfter: true
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Stub the queue/history endpoints per `scenario`, close the WS, and wait
|
||||
* for the auto-reconnect to issue a fresh queue fetch.
|
||||
*/
|
||||
async function triggerReconnect(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute,
|
||||
scenario: Scenario,
|
||||
jobId: string
|
||||
): Promise<void> {
|
||||
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
|
||||
const queueFetches = await mockJobsRoute(
|
||||
comfyPage,
|
||||
QUEUE_ROUTE,
|
||||
scenario.queueBody(jobId)
|
||||
)
|
||||
const fetchesBeforeClose = queueFetches()
|
||||
await ws.close()
|
||||
await expect.poll(queueFetches).toBeGreaterThan(fetchesBeforeClose)
|
||||
}
|
||||
|
||||
test.describe('WebSocket reconnect with stale job', { tag: '@ui' }, () => {
|
||||
test.describe('app mode skeleton', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
// Skeleton visibility is the deterministic sync point: it appears
|
||||
// once both `storeJob` (HTTP) and `executionStart` (WS) have been
|
||||
// processed, regardless of arrival order.
|
||||
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
|
||||
await triggerReconnect(comfyPage, ws, scenario, jobId)
|
||||
|
||||
if (scenario.expectsActiveAfter) {
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
} else {
|
||||
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('preserves active job when the queue endpoint fails on reconnect', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
|
||||
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
|
||||
|
||||
// Prime queueStore.runningTasks with the active job — a WS status
|
||||
// event drives GraphView.onStatus -> queueStore.update().
|
||||
const primer = await mockJobsRoute(
|
||||
comfyPage,
|
||||
QUEUE_ROUTE,
|
||||
JSON.stringify(
|
||||
jobsResponse([
|
||||
{ id: jobId, status: 'in_progress', create_time: Date.now() }
|
||||
])
|
||||
)
|
||||
)
|
||||
exec.status(1)
|
||||
await expect.poll(primer).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Swap to a failing handler so the reconnect-driven fetch 500s.
|
||||
// The fix should preserve runningTasks from the priming call rather
|
||||
// than overwriting it with empty/error state.
|
||||
await comfyPage.page.unroute(QUEUE_ROUTE)
|
||||
const failed = await mockJobsRoute(comfyPage, QUEUE_ROUTE, '{}', 500)
|
||||
|
||||
const before = failed()
|
||||
await ws.close()
|
||||
await expect.poll(failed).toBeGreaterThan(before)
|
||||
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('vue node executing class', { tag: '@vue-nodes' }, () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
// The executing outline lives on the outer `[data-node-id]`
|
||||
// container, not the inner wrapper.
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(KSAMPLER_NODE)
|
||||
await expect(ksamplerNode).toBeVisible()
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
exec.progressState(jobId, {
|
||||
[KSAMPLER_NODE]: {
|
||||
value: 0,
|
||||
max: 1,
|
||||
state: 'running',
|
||||
node_id: KSAMPLER_NODE,
|
||||
display_node_id: KSAMPLER_NODE,
|
||||
prompt_id: jobId
|
||||
}
|
||||
})
|
||||
|
||||
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
|
||||
|
||||
await triggerReconnect(comfyPage, ws, scenario, jobId)
|
||||
|
||||
if (scenario.expectsActiveAfter) {
|
||||
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
|
||||
} else {
|
||||
await expect(ksamplerNode).not.toHaveClass(EXECUTING_CLASS)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.2",
|
||||
"version": "1.45.4",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
@@ -44,24 +44,24 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function isSectionCollapsed(nodeId: string): boolean {
|
||||
function isSectionCollapsed(nodeId: NodeId): boolean {
|
||||
// Defaults to collapsed when not explicitly set by the user
|
||||
return collapseMap[nodeId] ?? true
|
||||
}
|
||||
|
||||
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
|
||||
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
|
||||
collapseMap[nodeId] = collapsed
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return searchedWidgetsSectionDataList.value.every(({ node }) =>
|
||||
isSectionCollapsed(String(node.id))
|
||||
isSectionCollapsed(node.id)
|
||||
)
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const { node } of widgetsSectionDataList.value) {
|
||||
setSectionCollapsed(String(node.id), collapse)
|
||||
setSectionCollapsed(node.id, collapse)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -101,7 +101,7 @@ async function searcher(query: string) {
|
||||
:key="node.id"
|
||||
:node
|
||||
:widgets
|
||||
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
|
||||
:collapse="isSectionCollapsed(node.id) && !isSearching"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
? ''
|
||||
@@ -109,7 +109,7 @@ async function searcher(query: string) {
|
||||
"
|
||||
show-locate-button
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="setSectionCollapsed(String(node.id), $event)"
|
||||
@update:collapse="setSectionCollapsed(node.id, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -68,19 +68,19 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function isSectionCollapsed(nodeId: string): boolean {
|
||||
function isSectionCollapsed(nodeId: NodeId): boolean {
|
||||
// When not explicitly set, sections are collapsed if multiple nodes are selected
|
||||
return collapseMap[nodeId] ?? isMultipleNodesSelected.value
|
||||
}
|
||||
|
||||
function setSectionCollapsed(nodeId: string, collapsed: boolean) {
|
||||
function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
|
||||
collapseMap[nodeId] = collapsed
|
||||
}
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
const normalAllCollapsed = searchedWidgetsSectionDataList.value.every(
|
||||
({ node }) => isSectionCollapsed(String(node.id))
|
||||
({ node }) => isSectionCollapsed(node.id)
|
||||
)
|
||||
const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0
|
||||
return hasAdvanced
|
||||
@@ -89,7 +89,7 @@ const isAllCollapsed = computed({
|
||||
},
|
||||
set(collapse: boolean) {
|
||||
for (const { node } of widgetsSectionDataList.value) {
|
||||
setSectionCollapsed(String(node.id), collapse)
|
||||
setSectionCollapsed(node.id, collapse)
|
||||
}
|
||||
advancedCollapsed.value = collapse
|
||||
}
|
||||
@@ -154,7 +154,7 @@ const advancedLabel = computed(() => {
|
||||
:node
|
||||
:label
|
||||
:widgets
|
||||
:collapse="isSectionCollapsed(String(node.id)) && !isSearching"
|
||||
:collapse="isSectionCollapsed(node.id) && !isSearching"
|
||||
:show-locate-button="isMultipleNodesSelected"
|
||||
:tooltip="
|
||||
isSearching || widgets.length
|
||||
@@ -162,7 +162,7 @@ const advancedLabel = computed(() => {
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="setSectionCollapsed(String(node.id), $event)"
|
||||
@update:collapse="setSectionCollapsed(node.id, $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
|
||||
|
||||
88
src/composables/useReconnectQueueRefresh.test.ts
Normal file
88
src/composables/useReconnectQueueRefresh.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
function makeJob(id: string, status: JobListItem['status']): JobListItem {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
create_time: 0,
|
||||
update_time: 0,
|
||||
last_state_update: 0,
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getQueue: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
apiURL: vi.fn((p: string) => `/api${p}`)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useReconnectQueueRefresh', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
vi.mocked(api.getQueue).mockResolvedValue({ Running: [], Pending: [] })
|
||||
vi.mocked(api.getHistory).mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('forwards running+pending job ids to clearActiveJobIfStale', async () => {
|
||||
vi.mocked(api.getQueue).mockResolvedValue({
|
||||
Running: [makeJob('run-1', 'in_progress')],
|
||||
Pending: [makeJob('pend-1', 'pending'), makeJob('pend-2', 'pending')]
|
||||
})
|
||||
const executionStore = useExecutionStore()
|
||||
const clearSpy = vi
|
||||
.spyOn(executionStore, 'clearActiveJobIfStale')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const refresh = useReconnectQueueRefresh()
|
||||
await refresh()
|
||||
|
||||
expect(clearSpy).toHaveBeenCalledTimes(1)
|
||||
expect(clearSpy).toHaveBeenCalledWith(
|
||||
new Set(['run-1', 'pend-1', 'pend-2'])
|
||||
)
|
||||
})
|
||||
|
||||
it('passes an empty set when the queue is genuinely empty', async () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const clearSpy = vi
|
||||
.spyOn(executionStore, 'clearActiveJobIfStale')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const refresh = useReconnectQueueRefresh()
|
||||
await refresh()
|
||||
|
||||
expect(clearSpy).toHaveBeenCalledWith(new Set())
|
||||
})
|
||||
|
||||
it('reuses the prior queue snapshot when the fetch fails, so a still-running job is not falsely cleared', async () => {
|
||||
vi.mocked(api.getQueue)
|
||||
.mockResolvedValueOnce({
|
||||
Running: [makeJob('run-1', 'in_progress')],
|
||||
Pending: []
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
const executionStore = useExecutionStore()
|
||||
const clearSpy = vi
|
||||
.spyOn(executionStore, 'clearActiveJobIfStale')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const refresh = useReconnectQueueRefresh()
|
||||
await refresh() // primes the store with run-1
|
||||
await refresh() // network failure here — store must not go empty
|
||||
|
||||
expect(clearSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
|
||||
})
|
||||
})
|
||||
25
src/composables/useReconnectQueueRefresh.ts
Normal file
25
src/composables/useReconnectQueueRefresh.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
* After a WebSocket reconnect, refresh the queue from the server and clear
|
||||
* any active job that finished during the disconnect window. Returns the
|
||||
* handler so the caller can wire it to the `reconnected` api event.
|
||||
*
|
||||
* `update()` preserves the previous queue snapshot when the fetch fails, so
|
||||
* if the network is still flaky we reconcile against the last known good
|
||||
* state rather than an empty (and falsely "stale") set.
|
||||
*/
|
||||
export function useReconnectQueueRefresh() {
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
return async function refreshOnReconnect() {
|
||||
await queueStore.update()
|
||||
const activeJobIds = new Set([
|
||||
...queueStore.runningTasks.map((t) => t.jobId),
|
||||
...queueStore.pendingTasks.map((t) => t.jobId)
|
||||
])
|
||||
executionStore.clearActiveJobIfStale(activeJobIds)
|
||||
}
|
||||
}
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "قيمة منطقية",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "PBR"
|
||||
},
|
||||
"quad": {
|
||||
"name": "رباعي"
|
||||
"name": "رباعي",
|
||||
"tooltip": "هذا المعامل قديم ولم يعد له أي تأثير."
|
||||
},
|
||||
"texture": {
|
||||
"name": "الملمس"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "الصوت"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "مقياس حقن الصوت",
|
||||
"tooltip": "المقياس لميزات الصوت عند حقنها في نموذج الفيديو."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "إطارات الفيديو"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "مخرجات مشفر الصوت",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "سلسلة معدل الإطارات (fps)",
|
||||
"tooltip": "معدل الإطارات المحسوب بناءً على طول الصوت وعدد إطارات الفيديو. يُستخدم في الموجه."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "الصوت",
|
||||
"tooltip": "الصوت المستخدم لحساب إجمالي إطارات الإخراج واستخراج صوت المقطع."
|
||||
},
|
||||
"images": {
|
||||
"name": "الصور"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "فهرس المقطع",
|
||||
"tooltip": "أي مقطع هذا (٠ للأول، ١ للثاني، إلخ.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "طول المقطع",
|
||||
"tooltip": "طول هذا المقطع (عادةً ١٤٩ إطاراً)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تسلسل الإطارات الرئيسية المبطنة",
|
||||
"tooltip": "تسلسل الإطارات الرئيسية بعد التبطين"
|
||||
},
|
||||
"1": {
|
||||
"name": "قناع الإطارات الرئيسية",
|
||||
"tooltip": "قناع يحدد الإطارات الصالحة"
|
||||
},
|
||||
"2": {
|
||||
"name": "مقطع الصوت",
|
||||
"tooltip": "مقطع الصوت لهذا الجزء من الفيديو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "الصوت",
|
||||
"tooltip": "الصوت الذي سيتم تقطيعه لكل مقطع صادر."
|
||||
},
|
||||
"images": {
|
||||
"name": "الصور"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "عدد المقاطع",
|
||||
"tooltip": "عدد المقاطع المبطنة التي سيتم إصدارها كقوائم."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "طول المقطع",
|
||||
"tooltip": "طول كل مقطع (عادةً ١٤٩ إطاراً)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "تسلسلات الإطارات الرئيسية المبطنة",
|
||||
"tooltip": "تسلسلات الإطارات الرئيسية بعد التبطين"
|
||||
},
|
||||
"1": {
|
||||
"name": "أقنعة الإطارات الرئيسية",
|
||||
"tooltip": "أقنعة تحدد الإطارات الصالحة"
|
||||
},
|
||||
"2": {
|
||||
"name": "مقطع الصوت",
|
||||
"tooltip": "مقطع الصوت لكل جزء من الفيديو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "مخرجات ترميز الصوت"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "تضمينات CLIP للرؤية للإطار الأول."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "تضمينات CLIP للرؤية لصورة المرجع."
|
||||
},
|
||||
"height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"length": {
|
||||
"name": "الطول",
|
||||
"tooltip": "عدد الإطارات في الفيديو المُنتج. يجب أن يبقى ١٤٩ لـ WanDancer."
|
||||
},
|
||||
"mask": {
|
||||
"name": "قناع",
|
||||
"tooltip": "قناع معالجة الصورة للصورة/الصور الابتدائية. الأبيض يبقى، الأسود يُولّد. يُستخدم للتوليد المحلي."
|
||||
},
|
||||
"negative": {
|
||||
"name": "سلبي"
|
||||
},
|
||||
"positive": {
|
||||
"name": "إيجابي"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "الصورة الابتدائية",
|
||||
"tooltip": "الصورة أو الصور الأولية التي سيتم ترميزها، يمكن أن تكون أي عدد من الإطارات."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "إيجابي",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "سلبي",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "كامِن",
|
||||
"tooltip": "كامِن فارغ."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "وان إطار أول وآخر إلى فيديو",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17430,7 +17434,8 @@
|
||||
"name": "face_limit"
|
||||
},
|
||||
"quad": {
|
||||
"name": "quad"
|
||||
"name": "quad",
|
||||
"tooltip": "This parameter is deprecated and does nothing."
|
||||
},
|
||||
"geometry_quality": {
|
||||
"name": "geometry_quality"
|
||||
@@ -19428,6 +19433,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "The scale for the audio features when injected into the video model."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "The calculated fps based on the audio length and the number of video frames. Used in the prompt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Length of this segment (usually 149 frames)"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "Which segment this is (0 for first, 1 for second, etc.)"
|
||||
},
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio to calculate total output frames from and extract segment audio."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Padded keyframe sequence"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Mask indicating valid frames"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Audio segment for this video segment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Length of each segment (usually 149 frames)"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "How many padded segments to emit as lists."
|
||||
},
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio to slice for each emitted segment."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Padded keyframe sequences"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Masks indicating valid frames"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Audio segment for each video segment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"length": {
|
||||
"name": "length",
|
||||
"tooltip": "The number of frames in the generated video. Should stay 149 for WanDancer."
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "The CLIP vision embeds for the first frame."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "The CLIP vision embeds for the reference image."
|
||||
},
|
||||
"start_image": {
|
||||
"name": "start_image",
|
||||
"tooltip": "The initial image(s) to be encoded, can be any number of frames."
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "Image conditioning mask for the start image(s). White is kept, black is generated. Used for the local generations."
|
||||
},
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "Empty latent."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "cuadrilátero"
|
||||
"name": "cuadrilátero",
|
||||
"tooltip": "Este parámetro está obsoleto y no hace nada."
|
||||
},
|
||||
"texture": {
|
||||
"name": "textura"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "La escala para las características de audio cuando se inyectan en el modelo de video."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "Los fps calculados en base a la duración del audio y el número de fotogramas de video. Se usa en el prompt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio para calcular el total de fotogramas de salida y extraer el audio del segmento."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "Qué segmento es este (0 para el primero, 1 para el segundo, etc.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Longitud de este segmento (usualmente 149 fotogramas)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Secuencia de keyframes rellenada"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Máscara que indica los fotogramas válidos"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Segmento de audio para este segmento de video"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio para dividir para cada segmento emitido."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "Cuántos segmentos rellenados emitir como listas."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Longitud de cada segmento (usualmente 149 fotogramas)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Secuencias de keyframes rellenadas"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Máscaras que indican los fotogramas válidos"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Segmento de audio para cada segmento de video"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "Las incrustaciones de visión de CLIP para el primer fotograma."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "Las incrustaciones de visión de CLIP para la imagen de referencia."
|
||||
},
|
||||
"height": {
|
||||
"name": "alto"
|
||||
},
|
||||
"length": {
|
||||
"name": "longitud",
|
||||
"tooltip": "El número de fotogramas en el video generado. Debe mantenerse en 149 para WanDancer."
|
||||
},
|
||||
"mask": {
|
||||
"name": "máscara",
|
||||
"tooltip": "Máscara de acondicionamiento de imagen para la(s) imagen(es) inicial(es). El blanco se mantiene, el negro se genera. Se utiliza para las generaciones locales."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negativo"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positivo"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "imagen_inicial",
|
||||
"tooltip": "La(s) imagen(es) inicial(es) a codificar, puede ser cualquier cantidad de fotogramas."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "ancho"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positivo",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negativo",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latente",
|
||||
"tooltip": "Latente vacío."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "بولین",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "PBR"
|
||||
},
|
||||
"quad": {
|
||||
"name": "چهارضلعی"
|
||||
"name": "چهارضلعی",
|
||||
"tooltip": "این پارامتر منسوخ شده است و هیچ تأثیری ندارد."
|
||||
},
|
||||
"texture": {
|
||||
"name": "تکسچر"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "مقیاس ویژگیهای صوتی هنگام تزریق به مدل ویدیو."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "نرخ فریم بر ثانیه (fps) محاسبهشده بر اساس طول صوت و تعداد فریمهای ویدیو. در prompt استفاده میشود."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "صوت برای محاسبه تعداد کل فریمهای خروجی و استخراج صوت بخش."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "این بخش کدام است (۰ برای اول، ۱ برای دوم و ...)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "طول این بخش (معمولاً ۱۴۹ فریم)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "دنباله keyframe با padding"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "ماسک نشاندهنده فریمهای معتبر"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "بخش صوتی برای این بخش ویدیو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "صوت برای برش هر بخش خروجی."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "تعداد بخشهای padding که به صورت لیست خروجی داده میشود."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "طول هر بخش (معمولاً ۱۴۹ فریم)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "دنبالههای keyframe با padding"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "ماسکها برای نشان دادن فریمهای معتبر"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "بخش صوتی برای هر بخش ویدیو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "خروجی رمزگذار صوتی"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "خروجی بینایی clip",
|
||||
"tooltip": "بردارهای بینایی CLIP برای اولین فریم."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "خروجی مرجع بینایی clip",
|
||||
"tooltip": "بردارهای بینایی CLIP برای تصویر مرجع."
|
||||
},
|
||||
"height": {
|
||||
"name": "ارتفاع"
|
||||
},
|
||||
"length": {
|
||||
"name": "طول",
|
||||
"tooltip": "تعداد فریمهای ویدئوی تولیدشده. برای WanDancer باید ۱۴۹ باقی بماند."
|
||||
},
|
||||
"mask": {
|
||||
"name": "ماسک",
|
||||
"tooltip": "ماسک شرطیسازی تصویر برای تصویر(ها)ی شروع. سفید حفظ میشود، سیاه تولید میشود. برای تولیدات محلی استفاده میشود."
|
||||
},
|
||||
"negative": {
|
||||
"name": "منفی"
|
||||
},
|
||||
"positive": {
|
||||
"name": "مثبت"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "تصویر شروع",
|
||||
"tooltip": "تصویر(ها)ی اولیه برای رمزگذاری؛ میتواند هر تعداد فریم باشد."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "عرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "مثبت",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "منفی",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "لاتنت",
|
||||
"tooltip": "لاتنت خالی."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "quad"
|
||||
"name": "quad",
|
||||
"tooltip": "Ce paramètre est obsolète et n'a aucun effet."
|
||||
},
|
||||
"texture": {
|
||||
"name": "texture"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "L'échelle des caractéristiques audio lors de leur injection dans le modèle vidéo."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "Le nombre d'images par seconde calculé en fonction de la durée de l'audio et du nombre d'images vidéo. Utilisé dans l'invite."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio pour calculer le nombre total d'images de sortie et extraire l'audio du segment."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "Quel segment est-ce (0 pour le premier, 1 pour le second, etc.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Longueur de ce segment (généralement 149 images)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Séquence de keyframes complétée"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Masque indiquant les images valides"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Segment audio pour ce segment vidéo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Audio à découper pour chaque segment émis."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "Combien de segments complétés à émettre sous forme de listes."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Longueur de chaque segment (généralement 149 images)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Séquences de keyframes complétées"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Masques indiquant les images valides"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Segment audio pour chaque segment vidéo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "Les embeddings CLIP vision pour la première image."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "Les embeddings CLIP vision pour l’image de référence."
|
||||
},
|
||||
"height": {
|
||||
"name": "hauteur"
|
||||
},
|
||||
"length": {
|
||||
"name": "longueur",
|
||||
"tooltip": "Le nombre d’images dans la vidéo générée. Doit rester à 149 pour WanDancer."
|
||||
},
|
||||
"mask": {
|
||||
"name": "masque",
|
||||
"tooltip": "Masque de conditionnement d’image pour l’image ou les images de départ. Le blanc est conservé, le noir est généré. Utilisé pour les générations locales."
|
||||
},
|
||||
"negative": {
|
||||
"name": "négatif"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positif"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "image_de_départ",
|
||||
"tooltip": "L’image ou les images initiales à encoder, peut contenir n’importe quel nombre d’images."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "largeur"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positif",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "négatif",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "Latent vide."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "quad"
|
||||
"name": "quad",
|
||||
"tooltip": "このパラメータは非推奨であり、何も行いません。"
|
||||
},
|
||||
"texture": {
|
||||
"name": "texture"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "オーディオ特徴量をビデオモデルに注入する際のスケール。"
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "オーディオの長さとビデオフレーム数から計算されたfps。プロンプトで使用されます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "出力フレーム総数の計算やセグメントオーディオの抽出に使用するオーディオ。"
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "このセグメントがどれか(最初は0、次は1など)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "このセグメントの長さ(通常は149フレーム)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "パディングされたキーフレームシーケンス"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "有効なフレームを示すマスク"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "このビデオセグメント用のオーディオセグメント"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "各出力セグメント用にスライスするオーディオ。"
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "リストとして出力するパディング済みセグメントの数。"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "各セグメントの長さ(通常は149フレーム)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "パディングされたキーフレームシーケンス"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "有効なフレームを示すマスク"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "各ビデオセグメント用のオーディオセグメント"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "最初のフレームのCLIP vision埋め込み。"
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "参照画像のCLIP vision埋め込み。"
|
||||
},
|
||||
"height": {
|
||||
"name": "高さ"
|
||||
},
|
||||
"length": {
|
||||
"name": "長さ",
|
||||
"tooltip": "生成される動画のフレーム数。WanDancerの場合は149のままにしてください。"
|
||||
},
|
||||
"mask": {
|
||||
"name": "マスク",
|
||||
"tooltip": "開始画像の画像処理用マスク。白は保持、黒は生成されます。ローカル生成に使用されます。"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "開始画像",
|
||||
"tooltip": "エンコードする初期画像。任意のフレーム数を指定できます。"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "幅"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "空のlatent。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "PBR"
|
||||
},
|
||||
"quad": {
|
||||
"name": "쿼드"
|
||||
"name": "쿼드",
|
||||
"tooltip": "이 매개변수는 더 이상 사용되지 않으며 아무런 동작도 하지 않습니다."
|
||||
},
|
||||
"texture": {
|
||||
"name": "텍스처"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "오디오 특징을 비디오 모델에 주입할 때의 스케일입니다."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "오디오 길이와 비디오 프레임 수를 기반으로 계산된 fps입니다. 프롬프트에 사용됩니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "총 출력 프레임 계산 및 구간 오디오 추출에 사용할 오디오입니다."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "이 구간이 몇 번째인지 (첫 번째는 0, 두 번째는 1 등)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "이 구간의 길이 (보통 149 프레임)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "패딩된 키프레임 시퀀스"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "유효한 프레임을 나타내는 마스크"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "이 비디오 구간에 해당하는 오디오 구간"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "각 출력 구간에 맞게 오디오를 분할합니다."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "리스트로 출력할 패딩된 구간의 개수"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "각 구간의 길이 (보통 149 프레임)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "패딩된 키프레임 시퀀스들"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "유효한 프레임을 나타내는 마스크들"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "각 비디오 구간에 해당하는 오디오 구간"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "첫 번째 프레임의 CLIP 비전 임베딩입니다."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "참조 이미지의 CLIP 비전 임베딩입니다."
|
||||
},
|
||||
"height": {
|
||||
"name": "높이"
|
||||
},
|
||||
"length": {
|
||||
"name": "길이",
|
||||
"tooltip": "생성된 비디오의 프레임 수입니다. WanDancer의 경우 149로 유지해야 합니다."
|
||||
},
|
||||
"mask": {
|
||||
"name": "마스크",
|
||||
"tooltip": "시작 이미지(들)에 대한 이미지 조건 마스크입니다. 흰색은 유지되고, 검은색은 생성됩니다. 로컬 생성에 사용됩니다."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "시작 이미지",
|
||||
"tooltip": "인코딩할 초기 이미지(들)입니다. 프레임 수는 자유롭게 설정할 수 있습니다."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "너비"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "비어 있는 latent."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WAN 비디오 생성 (시작-끝 프레임)",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "quad"
|
||||
"name": "quad",
|
||||
"tooltip": "Este parâmetro está obsoleto e não faz nada."
|
||||
},
|
||||
"texture": {
|
||||
"name": "textura"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "áudio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "escala_de_injeção_de_áudio",
|
||||
"tooltip": "A escala para as características do áudio ao serem injetadas no modelo de vídeo."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "quadros_de_vídeo"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "saída_do_codificador_de_áudio",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "string_fps",
|
||||
"tooltip": "O fps calculado com base na duração do áudio e no número de quadros de vídeo. Usado no prompt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "áudio",
|
||||
"tooltip": "Áudio para calcular o total de quadros de saída e extrair o áudio do segmento."
|
||||
},
|
||||
"images": {
|
||||
"name": "imagens"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "índice_do_segmento",
|
||||
"tooltip": "Qual segmento é este (0 para o primeiro, 1 para o segundo, etc.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "comprimento_do_segmento",
|
||||
"tooltip": "Comprimento deste segmento (geralmente 149 quadros)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "sequência_de_keyframes",
|
||||
"tooltip": "Sequência de keyframes preenchida"
|
||||
},
|
||||
"1": {
|
||||
"name": "máscara_de_keyframes",
|
||||
"tooltip": "Máscara indicando quadros válidos"
|
||||
},
|
||||
"2": {
|
||||
"name": "segmento_de_áudio",
|
||||
"tooltip": "Segmento de áudio para este segmento de vídeo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "áudio",
|
||||
"tooltip": "Áudio a ser dividido para cada segmento emitido."
|
||||
},
|
||||
"images": {
|
||||
"name": "imagens"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "número_de_segmentos",
|
||||
"tooltip": "Quantos segmentos preenchidos emitir como listas."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "comprimento_do_segmento",
|
||||
"tooltip": "Comprimento de cada segmento (geralmente 149 quadros)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "sequências_de_keyframes",
|
||||
"tooltip": "Sequências de keyframes preenchidas"
|
||||
},
|
||||
"1": {
|
||||
"name": "máscaras_de_keyframes",
|
||||
"tooltip": "Máscaras indicando quadros válidos"
|
||||
},
|
||||
"2": {
|
||||
"name": "segmento_de_áudio",
|
||||
"tooltip": "Segmento de áudio para cada segmento de vídeo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "Os embeddings de visão do CLIP para o primeiro quadro."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "Os embeddings de visão do CLIP para a imagem de referência."
|
||||
},
|
||||
"height": {
|
||||
"name": "altura"
|
||||
},
|
||||
"length": {
|
||||
"name": "duração",
|
||||
"tooltip": "O número de quadros no vídeo gerado. Deve permanecer 149 para WanDancer."
|
||||
},
|
||||
"mask": {
|
||||
"name": "máscara",
|
||||
"tooltip": "Máscara de condicionamento de imagem para a(s) imagem(ns) inicial(is). Branco é mantido, preto é gerado. Usado para as gerações locais."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negativo"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positivo"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "imagem_inicial",
|
||||
"tooltip": "A(s) imagem(ns) inicial(is) a serem codificadas, pode ser qualquer quantidade de quadros."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "largura"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positivo",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negativo",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latente",
|
||||
"tooltip": "Latente vazio."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "PBR"
|
||||
},
|
||||
"quad": {
|
||||
"name": "квад"
|
||||
"name": "квад",
|
||||
"tooltip": "Этот параметр устарел и больше не используется."
|
||||
},
|
||||
"texture": {
|
||||
"name": "текстура"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "Масштаб аудиофич при внедрении в видеомодель."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "Вычисленный fps на основе длины аудио и количества видеокадров. Используется в prompt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Аудио для расчёта общего количества выходных кадров и извлечения сегмента аудио."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "Какой это сегмент (0 — первый, 1 — второй и т.д.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Длина этого сегмента (обычно 149 кадров)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Дополненная последовательность ключевых кадров"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Маска, указывающая валидные кадры"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Аудиосегмент для этого видеосегмента"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "Аудио для нарезки для каждого выдаваемого сегмента."
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "Сколько дополненных сегментов выдавать в виде списков."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "Длина каждого сегмента (обычно 149 кадров)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "Дополненные последовательности ключевых кадров"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "Маски, указывающие валидные кадры"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "Аудиосегмент для каждого видеосегмента"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "Визуальные эмбеддинги CLIP для первого кадра."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "Визуальные эмбеддинги CLIP для референсного изображения."
|
||||
},
|
||||
"height": {
|
||||
"name": "высота"
|
||||
},
|
||||
"length": {
|
||||
"name": "длина",
|
||||
"tooltip": "Количество кадров в сгенерированном видео. Для WanDancer должно оставаться 149."
|
||||
},
|
||||
"mask": {
|
||||
"name": "маска",
|
||||
"tooltip": "Маска для обработки изображения начального кадра(ов). Белое сохраняется, черное генерируется. Используется для локальной генерации."
|
||||
},
|
||||
"negative": {
|
||||
"name": "негативный"
|
||||
},
|
||||
"positive": {
|
||||
"name": "позитивный"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "начальное изображение",
|
||||
"tooltip": "Исходное изображение(я) для кодирования, может быть любое количество кадров."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "ширина"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "позитивный",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "негативный",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "латентный",
|
||||
"tooltip": "Пустое латентное пространство."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanFirstLastFrameToVideo",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "dörtgen"
|
||||
"name": "dörtgen",
|
||||
"tooltip": "Bu parametre kullanımdan kaldırılmıştır ve hiçbir şey yapmaz."
|
||||
},
|
||||
"texture": {
|
||||
"name": "doku"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "ses"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "ses_enjeksiyon_ölçeği",
|
||||
"tooltip": "Ses özelliklerinin video modeline enjekte edilirken kullanılacak ölçek."
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_kareleri"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "ses_kodlayıcı_çıktısı",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_dizgesi",
|
||||
"tooltip": "Ses uzunluğu ve video kare sayısına göre hesaplanan fps. İstem içinde kullanılır."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "ses",
|
||||
"tooltip": "Toplam çıktı karelerini hesaplamak ve segment sesini çıkarmak için ses."
|
||||
},
|
||||
"images": {
|
||||
"name": "görseller"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_indeksi",
|
||||
"tooltip": "Bu hangi segment (ilk için 0, ikinci için 1, vb.)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_uzunluğu",
|
||||
"tooltip": "Bu segmentin uzunluğu (genellikle 149 kare)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "anahtar_kare_dizisi",
|
||||
"tooltip": "Doldurulmuş anahtar kare dizisi"
|
||||
},
|
||||
"1": {
|
||||
"name": "anahtar_kare_maskesi",
|
||||
"tooltip": "Geçerli kareleri gösteren maske"
|
||||
},
|
||||
"2": {
|
||||
"name": "ses_segmenti",
|
||||
"tooltip": "Bu video segmenti için ses segmenti"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "ses",
|
||||
"tooltip": "Her üretilen segment için bölünecek ses."
|
||||
},
|
||||
"images": {
|
||||
"name": "görseller"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "segment_sayısı",
|
||||
"tooltip": "Liste olarak kaç doldurulmuş segment üretileceği."
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_uzunluğu",
|
||||
"tooltip": "Her segmentin uzunluğu (genellikle 149 kare)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "anahtar_kare_dizileri",
|
||||
"tooltip": "Doldurulmuş anahtar kare dizileri"
|
||||
},
|
||||
"1": {
|
||||
"name": "anahtar_kare_maskeleri",
|
||||
"tooltip": "Geçerli kareleri gösteren maskeler"
|
||||
},
|
||||
"2": {
|
||||
"name": "ses_segmenti",
|
||||
"tooltip": "Her video segmenti için ses segmenti"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "İlk kare için CLIP vision gömüleri."
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "Referans görsel için CLIP vision gömüleri."
|
||||
},
|
||||
"height": {
|
||||
"name": "yükseklik"
|
||||
},
|
||||
"length": {
|
||||
"name": "uzunluk",
|
||||
"tooltip": "Oluşturulan videodaki kare sayısı. WanDancer için 149 olarak kalmalıdır."
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "Başlangıç görsel(ler)i için görsel koşullandırma maskesi. Beyaz korunur, siyah üretilir. Yerel üretimler için kullanılır."
|
||||
},
|
||||
"negative": {
|
||||
"name": "negatif"
|
||||
},
|
||||
"positive": {
|
||||
"name": "pozitif"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "başlangıç görseli",
|
||||
"tooltip": "Kodlanacak ilk görsel(ler), herhangi bir kare sayısı olabilir."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "genişlik"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "pozitif",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negatif",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "Boş latent."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "WanİlkSonKaredenVideoya",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "BOOL",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "pbr"
|
||||
},
|
||||
"quad": {
|
||||
"name": "quad"
|
||||
"name": "quad",
|
||||
"tooltip": "此參數已棄用,無任何作用。"
|
||||
},
|
||||
"texture": {
|
||||
"name": "texture"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "audio_inject_scale",
|
||||
"tooltip": "將音訊特徵注入到影片模型時的縮放比例。"
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "video_frames"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "audio_encoder_output",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps_string",
|
||||
"tooltip": "根據音訊長度與影片影格數計算出的 fps,會用於提示詞中。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "用於計算總輸出影格數並擷取片段音訊的音訊。"
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "segment_index",
|
||||
"tooltip": "這是第幾個片段(第一個為 0,第二個為 1,以此類推)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "此片段的長度(通常為 149 影格)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "已補齊的關鍵影格序列"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "標示有效影格的 mask"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "此影片片段對應的音訊片段"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "要為每個輸出片段切割的音訊。"
|
||||
},
|
||||
"images": {
|
||||
"name": "images"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "num_segments",
|
||||
"tooltip": "要以清單形式輸出的補齊片段數量。"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "segment_length",
|
||||
"tooltip": "每個片段的長度(通常為 149 影格)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "keyframes_sequence",
|
||||
"tooltip": "已補齊的關鍵影格序列"
|
||||
},
|
||||
"1": {
|
||||
"name": "keyframes_mask",
|
||||
"tooltip": "標示有效影格的 mask"
|
||||
},
|
||||
"2": {
|
||||
"name": "audio_segment",
|
||||
"tooltip": "每個影片片段對應的音訊片段"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "audio_encoder_output"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip_vision_output",
|
||||
"tooltip": "第一幀的 CLIP 視覺嵌入。"
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip_vision_output_ref",
|
||||
"tooltip": "參考圖像的 CLIP 視覺嵌入。"
|
||||
},
|
||||
"height": {
|
||||
"name": "高度"
|
||||
},
|
||||
"length": {
|
||||
"name": "長度",
|
||||
"tooltip": "生成影片的幀數。對於 WanDancer,應保持 149。"
|
||||
},
|
||||
"mask": {
|
||||
"name": "遮罩",
|
||||
"tooltip": "起始圖像的圖像處理遮罩。白色保留,黑色生成。用於局部生成。"
|
||||
},
|
||||
"negative": {
|
||||
"name": "negative"
|
||||
},
|
||||
"positive": {
|
||||
"name": "positive"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "起始圖像",
|
||||
"tooltip": "要編碼的初始圖像,可為任意幀數。"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "寬度"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "空的 latent。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "Wan 首尾影格轉影片",
|
||||
"inputs": {
|
||||
|
||||
@@ -1689,6 +1689,10 @@
|
||||
},
|
||||
"1": {
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "布尔值",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17421,7 +17425,8 @@
|
||||
"name": "PBR"
|
||||
},
|
||||
"quad": {
|
||||
"name": "四边形"
|
||||
"name": "四边形",
|
||||
"tooltip": "此参数已弃用,无任何作用。"
|
||||
},
|
||||
"texture": {
|
||||
"name": "纹理"
|
||||
@@ -19389,6 +19394,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerEncodeAudio": {
|
||||
"display_name": "WanDancerEncodeAudio",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "音频"
|
||||
},
|
||||
"audio_inject_scale": {
|
||||
"name": "音频注入比例",
|
||||
"tooltip": "将音频特征注入到视频模型时的比例。"
|
||||
},
|
||||
"video_frames": {
|
||||
"name": "视频帧"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "音频编码器输出",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "fps字符串",
|
||||
"tooltip": "根据音频长度和视频帧数计算得到的fps。用于提示词中。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframes": {
|
||||
"display_name": "WanDancerPadKeyframes",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "音频",
|
||||
"tooltip": "用于计算总输出帧数并提取片段音频。"
|
||||
},
|
||||
"images": {
|
||||
"name": "图像"
|
||||
},
|
||||
"segment_index": {
|
||||
"name": "片段索引",
|
||||
"tooltip": "这是第几个片段(第一个为0,第二个为1,以此类推)"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "片段长度",
|
||||
"tooltip": "该片段的长度(通常为149帧)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "关键帧序列",
|
||||
"tooltip": "填充后的关键帧序列"
|
||||
},
|
||||
"1": {
|
||||
"name": "关键帧掩码",
|
||||
"tooltip": "指示有效帧的掩码"
|
||||
},
|
||||
"2": {
|
||||
"name": "音频片段",
|
||||
"tooltip": "该视频片段对应的音频片段"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerPadKeyframesList": {
|
||||
"display_name": "WanDancerPadKeyframesList",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "音频",
|
||||
"tooltip": "为每个输出片段切分音频。"
|
||||
},
|
||||
"images": {
|
||||
"name": "图像"
|
||||
},
|
||||
"num_segments": {
|
||||
"name": "片段数量",
|
||||
"tooltip": "要以列表形式输出多少个填充片段。"
|
||||
},
|
||||
"segment_length": {
|
||||
"name": "片段长度",
|
||||
"tooltip": "每个片段的长度(通常为149帧)"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "关键帧序列",
|
||||
"tooltip": "填充后的关键帧序列"
|
||||
},
|
||||
"1": {
|
||||
"name": "关键帧掩码",
|
||||
"tooltip": "指示有效帧的掩码"
|
||||
},
|
||||
"2": {
|
||||
"name": "音频片段",
|
||||
"tooltip": "每个视频片段对应的音频片段"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanDancerVideo": {
|
||||
"display_name": "WanDancerVideo",
|
||||
"inputs": {
|
||||
"audio_encoder_output": {
|
||||
"name": "音频编码器输出"
|
||||
},
|
||||
"clip_vision_output": {
|
||||
"name": "clip视觉输出",
|
||||
"tooltip": "第一帧的CLIP视觉嵌入。"
|
||||
},
|
||||
"clip_vision_output_ref": {
|
||||
"name": "clip视觉参考输出",
|
||||
"tooltip": "参考图像的CLIP视觉嵌入。"
|
||||
},
|
||||
"height": {
|
||||
"name": "高度"
|
||||
},
|
||||
"length": {
|
||||
"name": "长度",
|
||||
"tooltip": "生成视频的帧数。对于WanDancer应保持为149。"
|
||||
},
|
||||
"mask": {
|
||||
"name": "掩码",
|
||||
"tooltip": "用于起始图像的图像条件掩码。白色保留,黑色生成。用于局部生成。"
|
||||
},
|
||||
"negative": {
|
||||
"name": "负向"
|
||||
},
|
||||
"positive": {
|
||||
"name": "正向"
|
||||
},
|
||||
"start_image": {
|
||||
"name": "起始图像",
|
||||
"tooltip": "要编码的初始图像,可以为任意帧数。"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"width": {
|
||||
"name": "宽度"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "正向",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "负向",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "空的latent。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanFirstLastFrameToVideo": {
|
||||
"display_name": "Wan首尾帧视频",
|
||||
"inputs": {
|
||||
|
||||
@@ -1005,13 +1005,14 @@ export class ComfyApi extends EventTarget {
|
||||
* Gets the current state of the queue
|
||||
* @returns The currently running and queued items
|
||||
*/
|
||||
async getQueue(): Promise<{
|
||||
async getQueue(options?: { throwOnError?: boolean }): Promise<{
|
||||
Running: JobListItem[]
|
||||
Pending: JobListItem[]
|
||||
}> {
|
||||
try {
|
||||
return await fetchQueue(this.fetchApi.bind(this))
|
||||
} catch (error) {
|
||||
if (options?.throwOnError) throw error
|
||||
console.error('Failed to fetch queue:', error)
|
||||
return { Running: [], Pending: [] }
|
||||
}
|
||||
|
||||
@@ -24,20 +24,12 @@ function isActiveTracker(tracker: ChangeTracker): boolean {
|
||||
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
|
||||
}
|
||||
|
||||
const reportedInactiveCalls = new Set<string>()
|
||||
|
||||
/**
|
||||
* Report a ChangeTracker method being called on an inactive tracker —
|
||||
* a lifecycle violation that usually indicates stale extension state or
|
||||
* an incorrect call ordering. Reports once per method per workflow per
|
||||
* session so the signal is not drowned out by hot-path invocations while
|
||||
* still distinguishing between workflows.
|
||||
* an incorrect call ordering.
|
||||
*/
|
||||
function reportInactiveTrackerCall(method: string, workflowPath: string) {
|
||||
const key = `${method}:${workflowPath}`
|
||||
if (reportedInactiveCalls.has(key)) return
|
||||
reportedInactiveCalls.add(key)
|
||||
|
||||
console.warn(`${method}() called on inactive tracker for: ${workflowPath}`)
|
||||
|
||||
if (isDesktop) {
|
||||
|
||||
@@ -440,6 +440,57 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - clearActiveJobIfStale', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('clears the active job and progress state when not in the active set', () => {
|
||||
store.activeJobId = 'job-1'
|
||||
store.queuedJobs = { 'job-1': { nodes: { 'node-1': false } } }
|
||||
store.nodeProgressStates = {
|
||||
'node-1': {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running',
|
||||
node_id: 'node-1',
|
||||
display_node_id: 'node-1',
|
||||
prompt_id: 'job-1'
|
||||
}
|
||||
}
|
||||
|
||||
store.clearActiveJobIfStale(new Set(['job-2']))
|
||||
|
||||
expect(store.activeJobId).toBeNull()
|
||||
expect(store.queuedJobs['job-1']).toBeUndefined()
|
||||
expect(store.nodeProgressStates).toEqual({})
|
||||
})
|
||||
|
||||
it('preserves the active job when present in the active set', () => {
|
||||
store.activeJobId = 'job-1'
|
||||
store.queuedJobs = { 'job-1': { nodes: {} } }
|
||||
|
||||
store.clearActiveJobIfStale(new Set(['job-1', 'job-2']))
|
||||
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
expect(store.queuedJobs['job-1']).toBeDefined()
|
||||
})
|
||||
|
||||
it('is a no-op when there is no active job', () => {
|
||||
store.activeJobId = null
|
||||
store.queuedJobs = { other: { nodes: {} } }
|
||||
|
||||
store.clearActiveJobIfStale(new Set())
|
||||
|
||||
expect(store.activeJobId).toBeNull()
|
||||
expect(store.queuedJobs['other']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - progress_text startup guard', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
|
||||
@@ -485,6 +485,16 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
clearInitializationByJobIds(orphaned)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active job if the server's queue snapshot doesn't list it.
|
||||
* Used after WS reconnect to recover from stale state when a job finished
|
||||
* during the disconnect window.
|
||||
*/
|
||||
function clearActiveJobIfStale(activeJobIds: Set<JobId>) {
|
||||
const id = activeJobId.value
|
||||
if (id && !activeJobIds.has(id)) resetExecutionState(id)
|
||||
}
|
||||
|
||||
function isJobInitializing(jobId: JobId | number | undefined): boolean {
|
||||
if (!jobId) return false
|
||||
return initializingJobIds.value.has(String(jobId))
|
||||
@@ -643,6 +653,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
clearInitializationByJobId,
|
||||
clearInitializationByJobIds,
|
||||
reconcileInitializingJobs,
|
||||
clearActiveJobIfStale,
|
||||
bindExecutionEvents,
|
||||
unbindExecutionEvents,
|
||||
storeJob,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { TaskOutput } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
// Fixture factory for JobListItem
|
||||
@@ -340,11 +341,11 @@ describe('useQueueStore', () => {
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should clear loading state even if API fails', async () => {
|
||||
it('should clear loading state even if the queue fetch fails', async () => {
|
||||
mockGetQueue.mockRejectedValue(new Error('API error'))
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
|
||||
await expect(store.update()).rejects.toThrow('API error')
|
||||
await store.update()
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1018,10 +1019,9 @@ describe('useQueueStore', () => {
|
||||
const firstUpdate = store.update()
|
||||
void store.update() // coalesces, sets dirty
|
||||
|
||||
// First call rejects — but dirty flag triggers re-fetch
|
||||
await expect(firstUpdate).rejects.toThrow('network error')
|
||||
|
||||
// Re-fetch was triggered
|
||||
// First call resolves (allSettled absorbs the failure) but the dirty
|
||||
// flag still triggers a re-fetch when the in-flight request finishes.
|
||||
await firstUpdate
|
||||
expect(mockGetQueue).toHaveBeenCalledTimes(2)
|
||||
|
||||
resolveSecond({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
|
||||
@@ -1032,4 +1032,86 @@ describe('useQueueStore', () => {
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update() partial failures', () => {
|
||||
it('reconciles when the queue fetch succeeds, even with an empty snapshot', async () => {
|
||||
mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
const executionStore = useExecutionStore()
|
||||
const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledWith(new Set())
|
||||
})
|
||||
|
||||
it('preserves prior queue state and skips reconcile when the queue fetch fails', async () => {
|
||||
mockGetQueue
|
||||
.mockResolvedValueOnce({
|
||||
Running: [createRunningJob(0, 'run-1')],
|
||||
Pending: []
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
mockGetHistory.mockResolvedValue([])
|
||||
const executionStore = useExecutionStore()
|
||||
const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
|
||||
|
||||
await store.update()
|
||||
await store.update()
|
||||
|
||||
// First update reconciles with run-1; second update's queue fetch
|
||||
// rejects, so reconcile must not be called again.
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
expect(reconcileSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
|
||||
expect(store.runningTasks).toHaveLength(1)
|
||||
expect(store.runningTasks[0].jobId).toBe('run-1')
|
||||
})
|
||||
|
||||
it('still updates history when only the queue fetch fails', async () => {
|
||||
mockGetQueue.mockRejectedValue(new Error('queue down'))
|
||||
mockGetHistory.mockResolvedValue([createHistoryJob(0, 'hist-1')])
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(store.historyTasks).toHaveLength(1)
|
||||
expect(store.historyTasks[0].jobId).toBe('hist-1')
|
||||
})
|
||||
|
||||
it('still updates queue when only the history fetch fails', async () => {
|
||||
mockGetQueue.mockResolvedValue({
|
||||
Running: [createRunningJob(0, 'run-1')],
|
||||
Pending: []
|
||||
})
|
||||
mockGetHistory.mockRejectedValue(new Error('history down'))
|
||||
|
||||
await store.update()
|
||||
|
||||
expect(store.runningTasks).toHaveLength(1)
|
||||
expect(store.runningTasks[0].jobId).toBe('run-1')
|
||||
})
|
||||
|
||||
it('preserves prior state and skips reconcile when both fetches fail', async () => {
|
||||
mockGetQueue
|
||||
.mockResolvedValueOnce({
|
||||
Running: [createRunningJob(0, 'run-1')],
|
||||
Pending: []
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('queue down'))
|
||||
mockGetHistory
|
||||
.mockResolvedValueOnce([createHistoryJob(0, 'hist-1')])
|
||||
.mockRejectedValueOnce(new Error('history down'))
|
||||
const executionStore = useExecutionStore()
|
||||
const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
|
||||
|
||||
await store.update()
|
||||
await store.update()
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
expect(store.runningTasks).toHaveLength(1)
|
||||
expect(store.runningTasks[0].jobId).toBe('run-1')
|
||||
expect(store.historyTasks).toHaveLength(1)
|
||||
expect(store.historyTasks[0].jobId).toBe('hist-1')
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -525,68 +525,74 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
dirty = false
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [queue, history] = await Promise.all([
|
||||
api.getQueue(),
|
||||
const [queueResult, historyResult] = await Promise.allSettled([
|
||||
api.getQueue({ throwOnError: true }),
|
||||
api.getHistory(maxHistoryItems.value)
|
||||
])
|
||||
|
||||
// API returns pre-sorted data (sort_by=create_time&order=desc)
|
||||
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
|
||||
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
|
||||
if (queueResult.status === 'fulfilled') {
|
||||
const queue = queueResult.value
|
||||
// API returns pre-sorted data (sort_by=create_time&order=desc)
|
||||
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
|
||||
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
|
||||
|
||||
const currentHistory = toValue(historyTasks)
|
||||
const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
|
||||
const executionStore = useExecutionStore()
|
||||
appearedTasks.forEach((task) => {
|
||||
const jobIdString = String(task.jobId)
|
||||
const workflowId = task.workflowId
|
||||
if (workflowId && jobIdString) {
|
||||
executionStore.registerJobWorkflowIdMapping(jobIdString, workflowId)
|
||||
}
|
||||
})
|
||||
|
||||
const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
|
||||
const executionStore = useExecutionStore()
|
||||
appearedTasks.forEach((task) => {
|
||||
const jobIdString = String(task.jobId)
|
||||
const workflowId = task.workflowId
|
||||
if (workflowId && jobIdString) {
|
||||
executionStore.registerJobWorkflowIdMapping(jobIdString, workflowId)
|
||||
}
|
||||
})
|
||||
|
||||
// Only reconcile when the queue fetch returned data. api.getQueue()
|
||||
// returns empty Running/Pending on transient errors, which would
|
||||
// incorrectly clear all initializing prompts.
|
||||
const queueHasData = queue.Running.length > 0 || queue.Pending.length > 0
|
||||
if (queueHasData) {
|
||||
const activeJobIds = new Set([
|
||||
...queue.Running.map((j) => j.id),
|
||||
...queue.Pending.map((j) => j.id)
|
||||
])
|
||||
executionStore.reconcileInitializingJobs(activeJobIds)
|
||||
} else {
|
||||
console.error('Failed to fetch queue:', queueResult.reason)
|
||||
}
|
||||
|
||||
// Sort by create_time descending and limit to maxItems
|
||||
const sortedHistory = [...history]
|
||||
.sort((a, b) => b.create_time - a.create_time)
|
||||
.slice(0, toValue(maxHistoryItems))
|
||||
if (historyResult.status === 'fulfilled') {
|
||||
const history = historyResult.value
|
||||
const currentHistory = toValue(historyTasks)
|
||||
|
||||
// Reuse existing TaskItemImpl instances or create new
|
||||
// Must recreate if outputs_count changed (e.g., API started returning it)
|
||||
const existingByJobId = new Map(
|
||||
currentHistory.map((impl) => [impl.jobId, impl])
|
||||
)
|
||||
// Sort by create_time descending and limit to maxItems
|
||||
const sortedHistory = [...history]
|
||||
.sort((a, b) => b.create_time - a.create_time)
|
||||
.slice(0, toValue(maxHistoryItems))
|
||||
|
||||
const nextHistoryTasks = sortedHistory.map((job) => {
|
||||
const existing = existingByJobId.get(job.id)
|
||||
if (!existing) return new TaskItemImpl(job)
|
||||
// Recreate if outputs_count changed to ensure lazy loading works
|
||||
if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
|
||||
return new TaskItemImpl(job)
|
||||
// Reuse existing TaskItemImpl instances or create new
|
||||
// Must recreate if outputs_count changed (e.g., API started returning it)
|
||||
const existingByJobId = new Map(
|
||||
currentHistory.map((impl) => [impl.jobId, impl])
|
||||
)
|
||||
|
||||
const nextHistoryTasks = sortedHistory.map((job) => {
|
||||
const existing = existingByJobId.get(job.id)
|
||||
if (!existing) return new TaskItemImpl(job)
|
||||
// Recreate if outputs_count changed to ensure lazy loading works
|
||||
if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
|
||||
return new TaskItemImpl(job)
|
||||
}
|
||||
return existing
|
||||
})
|
||||
|
||||
const isHistoryUnchanged =
|
||||
nextHistoryTasks.length === currentHistory.length &&
|
||||
nextHistoryTasks.every(
|
||||
(task, index) => task === currentHistory[index]
|
||||
)
|
||||
|
||||
if (!isHistoryUnchanged) {
|
||||
historyTasks.value = nextHistoryTasks
|
||||
}
|
||||
return existing
|
||||
})
|
||||
|
||||
const isHistoryUnchanged =
|
||||
nextHistoryTasks.length === currentHistory.length &&
|
||||
nextHistoryTasks.every((task, index) => task === currentHistory[index])
|
||||
|
||||
if (!isHistoryUnchanged) {
|
||||
historyTasks.value = nextHistoryTasks
|
||||
hasFetchedHistorySnapshot.value = true
|
||||
} else {
|
||||
console.error('Failed to fetch history:', historyResult.reason)
|
||||
}
|
||||
hasFetchedHistorySnapshot.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
inFlight = false
|
||||
|
||||
214
src/views/GraphView.test.ts
Normal file
214
src/views/GraphView.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type * as VueUseCore from '@vueuse/core'
|
||||
import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
import type * as DistTypes from '@/platform/distribution/types'
|
||||
import type * as I18nModule from '@/i18n'
|
||||
|
||||
const apiMock = vi.hoisted(() => new EventTarget())
|
||||
|
||||
vi.mock('@/scripts/api', () => ({ api: apiMock }))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: { getNodeById: vi.fn(), nodes: [] },
|
||||
ui: {
|
||||
menuContainer: { style: { setProperty: vi.fn() } },
|
||||
restoreMenuPosition: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useReconnectQueueRefresh', () => {
|
||||
const refreshOnReconnect = vi.fn(async () => {})
|
||||
return { useReconnectQueueRefresh: () => refreshOnReconnect }
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useReconnectingNotification', () => {
|
||||
const onReconnected = vi.fn()
|
||||
const onReconnecting = vi.fn()
|
||||
return {
|
||||
useReconnectingNotification: () => ({ onReconnected, onReconnecting })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUseCore>()
|
||||
return { ...actual, useIntervalFn: vi.fn(() => ({ pause: vi.fn() })) }
|
||||
})
|
||||
|
||||
vi.mock('@/base/common/async', () => ({ runWhenGlobalIdle: vi.fn() }))
|
||||
vi.mock('@/composables/useBrowserTabTitle', () => ({
|
||||
useBrowserTabTitle: vi.fn()
|
||||
}))
|
||||
vi.mock('@/composables/useCoreCommands', () => ({ useCoreCommands: () => [] }))
|
||||
vi.mock('@/platform/remote/comfyui/useQueuePolling', () => ({
|
||||
useQueuePolling: vi.fn()
|
||||
}))
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandling: (f: unknown) => f,
|
||||
wrapWithErrorHandlingAsync: (f: unknown) => f
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/useProgressFavicon', () => ({
|
||||
useProgressFavicon: vi.fn()
|
||||
}))
|
||||
vi.mock('@/i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof I18nModule>()
|
||||
return { ...actual, loadLocale: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof DistTypes>()
|
||||
return { ...actual, isCloud: false, isDesktop: false }
|
||||
})
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: vi.fn(() => undefined), set: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
|
||||
vi.mock('@/platform/updates/common/useFrontendVersionMismatchWarning', () => ({
|
||||
useFrontendVersionMismatchWarning: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/updates/common/versionCompatibilityStore', () => ({
|
||||
useVersionCompatibilityStore: () => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', async () => {
|
||||
const { defineStore } = await import('pinia')
|
||||
return {
|
||||
useCanvasStore: defineStore('canvas-test-stub', () => ({
|
||||
linearMode: ref(false)
|
||||
}))
|
||||
}
|
||||
})
|
||||
vi.mock('@/services/autoQueueService', () => ({
|
||||
setupAutoQueueHandler: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/keybindings/keybindingService', () => ({
|
||||
useKeybindingService: () => ({
|
||||
registerCoreKeybindings: vi.fn(),
|
||||
keybindHandler: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ isBuilderMode: ref(false) })
|
||||
}))
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({ updateHistory: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ registerCommands: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
bindExecutionEvents: vi.fn(),
|
||||
unbindExecutionEvents: vi.fn(),
|
||||
activeJobId: null,
|
||||
clearActiveJobIfStale: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({ isAuthenticated: false })
|
||||
}))
|
||||
vi.mock('@/stores/menuItemStore', () => ({
|
||||
useMenuItemStore: () => ({ registerCoreMenuCommands: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/stores/modelStore', () => ({ useModelStore: () => ({}) }))
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({}),
|
||||
useNodeFrequencyStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
update: vi.fn(),
|
||||
runningTasks: [],
|
||||
pendingTasks: [],
|
||||
tasks: [],
|
||||
maxHistoryItems: 64
|
||||
}),
|
||||
useQueuePendingTaskCountStore: () => ({ update: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/stores/serverConfigStore', () => ({
|
||||
useServerConfigStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
|
||||
useBottomPanelStore: () => ({
|
||||
registerCoreBottomPanelTabs: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
}))
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: { light_theme: true, colors: { comfy_base: {} } }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => ({
|
||||
registerCoreSidebarTabs: vi.fn(),
|
||||
activeSidebarTabId: null
|
||||
})
|
||||
}))
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => ({
|
||||
changeTheme: vi.fn(),
|
||||
Events: { incrementUserProperty: vi.fn(), trackEvent: vi.fn() }
|
||||
})
|
||||
}))
|
||||
|
||||
// Module-mock heavy child components so we don't pay their import cost.
|
||||
const stubModule = { default: { template: '<div />' } }
|
||||
vi.mock('@/components/graph/GraphCanvas.vue', () => stubModule)
|
||||
vi.mock('@/views/LinearView.vue', () => stubModule)
|
||||
vi.mock('@/components/builder/BuilderToolbar.vue', () => stubModule)
|
||||
vi.mock('@/components/builder/BuilderMenu.vue', () => stubModule)
|
||||
vi.mock('@/components/builder/BuilderFooterToolbar.vue', () => stubModule)
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/components/ManagerProgressToast.vue',
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock(
|
||||
'@/platform/cloud/notification/components/DesktopCloudNotificationController.vue',
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock(
|
||||
'@/platform/assets/components/ModelImportProgressDialog.vue',
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock(
|
||||
'@/platform/assets/components/AssetExportProgressDialog.vue',
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/toasts/InviteAcceptedToast.vue',
|
||||
() => stubModule
|
||||
)
|
||||
vi.mock('@/components/toast/GlobalToast.vue', () => stubModule)
|
||||
vi.mock('@/components/toast/RerouteMigrationToast.vue', () => stubModule)
|
||||
vi.mock('@/components/MenuHamburger.vue', () => stubModule)
|
||||
vi.mock('@/components/dialog/UnloadWindowConfirmDialog.vue', () => stubModule)
|
||||
|
||||
describe('GraphView - reconnect wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('wires the reconnected event to the toast and queue refresh', async () => {
|
||||
const GraphView = (await import('./GraphView.vue')).default
|
||||
render(GraphView)
|
||||
|
||||
apiMock.dispatchEvent(new Event('reconnected'))
|
||||
|
||||
const { onReconnected } = useReconnectingNotification()
|
||||
const refreshOnReconnect = useReconnectQueueRefresh()
|
||||
await vi.waitFor(() => {
|
||||
expect(onReconnected).toHaveBeenCalledTimes(1)
|
||||
expect(refreshOnReconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,7 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||
@@ -248,11 +249,17 @@ const onExecutionSuccess = async () => {
|
||||
}
|
||||
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
const refreshOnReconnect = useReconnectQueueRefresh()
|
||||
|
||||
const handleReconnected = async () => {
|
||||
onReconnected()
|
||||
await refreshOnReconnect()
|
||||
}
|
||||
|
||||
useEventListener(api, 'status', onStatus)
|
||||
useEventListener(api, 'execution_success', onExecutionSuccess)
|
||||
useEventListener(api, 'reconnecting', onReconnecting)
|
||||
useEventListener(api, 'reconnected', onReconnected)
|
||||
useEventListener(api, 'reconnected', handleReconnected)
|
||||
|
||||
onMounted(() => {
|
||||
executionStore.bindExecutionEvents()
|
||||
|
||||
Reference in New Issue
Block a user