Compare commits
2 Commits
fix/dropdo
...
docs/weekl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a0ed7683 | ||
|
|
fac3a8c2bd |
@@ -19,26 +19,15 @@ reviews:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context:
|
||||
- the PR title
|
||||
- commit subjects in this PR
|
||||
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
|
||||
- the PR description.
|
||||
Do not rely on shell commands.
|
||||
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
|
||||
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
|
||||
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
|
||||
Fail if all of the following are true:
|
||||
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
|
||||
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
|
||||
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
|
||||
|
||||
Pass otherwise.
|
||||
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
Pass if at least one of the following is true:
|
||||
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes at least one file under `browser_tests/`.
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
- name: ADR compliance for entity/litegraph changes
|
||||
mode: warning
|
||||
instructions: |
|
||||
|
||||
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
@@ -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:
|
||||
|
||||
@@ -32,34 +32,16 @@ test.describe('Careers page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking a department button scrolls to and activates that section', async ({
|
||||
test('ENGINEERING category filter narrows the role list', async ({
|
||||
page
|
||||
}) => {
|
||||
const rolesSection = page.getByTestId('careers-roles')
|
||||
await rolesSection.scrollIntoViewIfNeeded()
|
||||
await expect(rolesSection).toBeVisible()
|
||||
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
|
||||
const engineeringButton = page.getByRole('button', {
|
||||
name: 'ENGINEERING',
|
||||
exact: true
|
||||
})
|
||||
|
||||
// RolesSection is hydrated via `client:visible`. Once the button responds
|
||||
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
|
||||
// the locator logic is in effect.
|
||||
await expect(async () => {
|
||||
await engineeringButton.click()
|
||||
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
|
||||
timeout: 1_000
|
||||
})
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
const engineeringSection = page.locator('#careers-dept-engineering')
|
||||
await expect(engineeringSection).toBeInViewport()
|
||||
|
||||
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
|
||||
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
|
||||
const LAST_SECTION_HASH = '#contact'
|
||||
|
||||
test.describe(
|
||||
'ContentSection scroll-spy @smoke',
|
||||
{
|
||||
annotation: [
|
||||
{
|
||||
type: 'issue',
|
||||
description:
|
||||
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
|
||||
},
|
||||
{
|
||||
type: 'environment',
|
||||
description:
|
||||
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
|
||||
}
|
||||
]
|
||||
},
|
||||
() => {
|
||||
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
|
||||
|
||||
test('activates the last badge when user scrolls to the bottom', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/privacy-policy')
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const badges = sidebarNav.getByRole('button')
|
||||
const lastBadge = badges.last()
|
||||
|
||||
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.scrollTo(0, document.documentElement.scrollHeight)
|
||||
)
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const lastBadge = sidebarNav.getByRole('button').last()
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,71 +1,27 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { demos, getNextDemo } from '../src/config/demos'
|
||||
import { t } from '../src/i18n/translations'
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
for (const demo of demos) {
|
||||
const nextDemo = getNextDemo(demo.slug)
|
||||
test('demo detail page renders hero and embed', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'Create a Video from an Image'
|
||||
)
|
||||
const iframe = page.locator('iframe[title*="Interactive demo"]')
|
||||
await expect(iframe).toBeAttached()
|
||||
})
|
||||
|
||||
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/demos/${demo.slug}`)
|
||||
test('demo detail page has transcript section', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t(demo.title, 'en'))
|
||||
|
||||
const ogImage = page.locator('head meta[property="og:image"]')
|
||||
await expect(ogImage).toHaveAttribute(
|
||||
'content',
|
||||
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
|
||||
)
|
||||
|
||||
const iframe = page.locator(
|
||||
`iframe[title*="${t('demos.embed.label', 'en')}"]`
|
||||
)
|
||||
await expect(iframe).toBeAttached()
|
||||
await expect(iframe).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(escapeRegExp(demo.arcadeId))
|
||||
)
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByText(t(nextDemo.title, 'en')).first()
|
||||
).toBeVisible()
|
||||
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
|
||||
await expect(nextThumb).toBeAttached()
|
||||
await expect(nextThumb).toBeVisible()
|
||||
const naturalWidth = await nextThumb.evaluate(
|
||||
(img) => (img as HTMLImageElement).naturalWidth
|
||||
)
|
||||
expect(naturalWidth).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/zh-CN/demos/${demo.slug}`)
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
|
||||
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
|
||||
|
||||
await expect(
|
||||
page.getByText(t(nextDemo.title, 'zh-CN')).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
test('demo detail page has next demo navigation', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByText(/what's next/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demo library page renders', async ({ page }) => {
|
||||
await page.goto('/demos')
|
||||
@@ -76,4 +32,13 @@ test.describe('Demo pages @smoke', () => {
|
||||
const response = await page.goto('/demos/nonexistent')
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('zh-CN demo page renders localized content', async ({ page }) => {
|
||||
await page.goto('/zh-CN/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'从图片创建视频'
|
||||
)
|
||||
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
|
||||
await expect(nextDemoLink).toBeAttached()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
@@ -48,105 +47,4 @@ test.describe('Mobile layout @mobile', () => {
|
||||
const mobileContainer = page.getByTestId('social-proof-mobile')
|
||||
await expect(mobileContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('SocialProofBar seamless marquee', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test('mobile forward marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
|
||||
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
|
||||
)
|
||||
expectSeamlessReverseLoop(geometry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Desktop SocialProofBar @smoke', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('desktop marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-desktop"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
})
|
||||
|
||||
type MarqueeGeometry = {
|
||||
copyWidths: number[]
|
||||
startPositions: number[]
|
||||
endPositions: number[]
|
||||
}
|
||||
|
||||
async function measureMarqueeLoopGeometry(
|
||||
page: Page,
|
||||
selector: string
|
||||
): Promise<MarqueeGeometry> {
|
||||
await page.locator(selector).first().waitFor()
|
||||
return page.evaluate((sel) => {
|
||||
const tracks = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(sel)
|
||||
).slice(0, 2)
|
||||
const firstAnimation = tracks[0]?.getAnimations()[0]
|
||||
if (!firstAnimation) {
|
||||
throw new Error(`No CSS animation found on ${sel}`)
|
||||
}
|
||||
const duration = firstAnimation.effect?.getTiming().duration
|
||||
if (typeof duration !== 'number' || duration <= 1) {
|
||||
throw new Error(
|
||||
`Animation on ${sel} has unusable duration: ${String(duration)}`
|
||||
)
|
||||
}
|
||||
const setAllTimes = (time: number) => {
|
||||
for (const track of tracks) {
|
||||
for (const anim of track.getAnimations()) {
|
||||
anim.currentTime = time
|
||||
}
|
||||
}
|
||||
void document.body.offsetWidth
|
||||
}
|
||||
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
|
||||
setAllTimes(0)
|
||||
const startPositions = readX()
|
||||
const copyWidths = tracks.map(
|
||||
(track) => track.getBoundingClientRect().width
|
||||
)
|
||||
setAllTimes(duration - 0.1)
|
||||
const endPositions = readX()
|
||||
return { copyWidths, startPositions, endPositions }
|
||||
}, selector)
|
||||
}
|
||||
|
||||
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
|
||||
const { copyWidths } = geometry
|
||||
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
|
||||
expect(copyWidths[0]).toBeGreaterThan(0)
|
||||
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
|
||||
// (when copy 1 jumps back to its start position) is visually indistinguishable.
|
||||
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
|
||||
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 69 B |
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Department } from '../../data/roles'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
@@ -16,72 +13,24 @@ const { locale = 'en', departments = [] } = defineProps<{
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
|
||||
const categories = computed(() =>
|
||||
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
|
||||
)
|
||||
|
||||
const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
|
||||
const activeCategory = ref('')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
|
||||
let isScrolling = false
|
||||
let pendingFrame = 0
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const ACTIVATION_OFFSET = 300
|
||||
|
||||
const deptElementId = (key: string) => `careers-dept-${key}`
|
||||
|
||||
function pickActiveSection() {
|
||||
pendingFrame = 0
|
||||
if (isScrolling) return
|
||||
const sections = sectionRefs.value as HTMLElement[]
|
||||
if (sections.length === 0) return
|
||||
|
||||
let active = sections[0]
|
||||
for (const el of sections) {
|
||||
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
|
||||
active = el
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
activeCategory.value = active.id.replace(/^careers-dept-/, '')
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingFrame !== 0) return
|
||||
pendingFrame = requestAnimationFrame(pickActiveSection)
|
||||
}
|
||||
|
||||
onMounted(pickActiveSection)
|
||||
useEventListener('scroll', scheduleUpdate, { passive: true })
|
||||
useEventListener('resize', scheduleUpdate, { passive: true })
|
||||
|
||||
function scrollToDepartment(deptKey: string) {
|
||||
activeCategory.value = deptKey
|
||||
isScrolling = true
|
||||
const el = document.getElementById(deptElementId(deptKey))
|
||||
if (!el) {
|
||||
isScrolling = false
|
||||
return
|
||||
}
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
pickActiveSection()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,10 +48,9 @@ function scrollToDepartment(deptKey: string) {
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
:model-value="activeCategory"
|
||||
class="mt-4"
|
||||
@update:model-value="scrollToDepartment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,11 +65,9 @@ function scrollToDepartment(deptKey: string) {
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="dept in visibleDepartments"
|
||||
:id="deptElementId(dept.key)"
|
||||
:ref="sectionRefs.set"
|
||||
v-for="dept in filteredDepartments"
|
||||
:key="dept.key"
|
||||
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
|
||||
class="mb-12 last:mb-0"
|
||||
>
|
||||
<SectionLabel>
|
||||
{{ dept.name }}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useTemplateRefsList
|
||||
} from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
@@ -44,25 +40,13 @@ const activeSection = ref(sections[0]?.id ?? '')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
sectionRefs,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
@@ -74,39 +58,22 @@ useIntersectionObserver(
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
const scrollBottom = window.scrollY + window.innerHeight
|
||||
return (
|
||||
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
}
|
||||
|
||||
function activateLastIfAtBottom() {
|
||||
if (isScrolling) return
|
||||
if (!isAtBottom()) return
|
||||
const lastId = sections[sections.length - 1]?.id
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
onMounted(activateLastIfAtBottom)
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
clearScrollLock()
|
||||
isScrolling = true
|
||||
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
clearScrollLock()
|
||||
isScrolling = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,28 +14,23 @@ const logos = [
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const mobileRow1Logos = logos.slice(0, 6)
|
||||
const mobileRow2Logos = logos.slice(6)
|
||||
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
|
||||
const row1 = logos.slice(0, 6)
|
||||
const mobileRow1 = [...row1, ...row1]
|
||||
const row2 = logos.slice(6)
|
||||
const mobileRow2 = [...row2, ...row2]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
|
||||
<div class="animate-marquee hidden items-center gap-2 md:flex">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in desktopLogos"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,38 +39,22 @@ const mobileRow2Logos = logos.slice(6)
|
||||
data-testid="social-proof-mobile"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="flex w-max gap-8">
|
||||
<div class="animate-marquee flex items-center gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in mobileRow1"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-max gap-8">
|
||||
<div class="animate-marquee-reverse flex items-center gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in mobileRow2"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,10 @@ import { t } from '../../i18n/translations'
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
aspectRatio = 16 / 9,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
aspectRatio?: number
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
@@ -26,8 +24,7 @@ const loaded = ref(false)
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
:style="{ aspectRatio }"
|
||||
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
|
||||
@@ -276,6 +276,29 @@ onUnmounted(() => {
|
||||
fill="#211927"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
x="300"
|
||||
y="150"
|
||||
width="250"
|
||||
height="900"
|
||||
fill="url(#localHeroFadeLeft)"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="localHeroFadeLeft"
|
||||
x1="550"
|
||||
y1="600"
|
||||
x2="300"
|
||||
y2="600"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,14 +15,6 @@ interface Demo {
|
||||
readonly transcript?: TranslationKey
|
||||
readonly publishedDate: string
|
||||
readonly modifiedDate: string
|
||||
/**
|
||||
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
|
||||
* landscape screencast). Sizes the embed container to match so rounded
|
||||
* corners hug the content instead of empty letterbox space. Source from
|
||||
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
|
||||
* invert it). Defaults to 16/9 if omitted.
|
||||
*/
|
||||
readonly aspectRatio?: number
|
||||
}
|
||||
|
||||
export const demos: readonly Demo[] = [
|
||||
@@ -40,8 +32,7 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
modifiedDate: '2026-04-19'
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
@@ -57,25 +48,7 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'community-workflows',
|
||||
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.community-workflows.title',
|
||||
description: 'demos.community-workflows.description',
|
||||
transcript: 'demos.community-workflows.transcript',
|
||||
ogImage: '/images/demos/community-workflows-og.png',
|
||||
thumbnail: '/images/demos/community-workflows-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'community', 'workflow', 'hub'],
|
||||
publishedDate: '2026-05-04',
|
||||
modifiedDate: '2026-05-04',
|
||||
aspectRatio: 1.931
|
||||
modifiedDate: '2026-04-19'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -3570,20 +3570,6 @@ const translations = {
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.community-workflows.title': {
|
||||
en: 'Explore and Use a Community Workflow from the Hub',
|
||||
'zh-CN': '探索并使用社区工作流'
|
||||
},
|
||||
'demos.community-workflows.description': {
|
||||
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
|
||||
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
|
||||
},
|
||||
'demos.community-workflows.transcript': {
|
||||
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
|
||||
@@ -121,7 +121,6 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
client:load
|
||||
/>
|
||||
|
||||
|
||||
@@ -122,7 +122,6 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
locale="zh-CN"
|
||||
client:load
|
||||
/>
|
||||
|
||||
@@ -101,13 +101,13 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-reverse {
|
||||
0% {
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
@@ -115,15 +115,11 @@
|
||||
}
|
||||
|
||||
@utility animate-marquee {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
|
||||
@utility animate-marquee-reverse {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
}
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ripple-effect {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export class VueNodeFixture {
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -26,7 +25,6 @@ export class VueNodeFixture {
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
@@ -8,9 +8,6 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
const DEPRECATED_NODE_TYPE = 'ImageBatch'
|
||||
const API_NODE_TYPE = 'FluxProUltraImageNode'
|
||||
|
||||
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
|
||||
test('Can add badge', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
@@ -144,73 +141,3 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
for (const vueEnabled of [false, true] as const) {
|
||||
const renderer = vueEnabled ? 'vue' : 'classic'
|
||||
const tag = vueEnabled
|
||||
? ['@vue-nodes', '@screenshot', '@node']
|
||||
: ['@screenshot', '@node']
|
||||
|
||||
test.describe(`Node lifecycle badge (${renderer})`, { tag }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
})
|
||||
|
||||
for (const mode of [NodeBadgeMode.ShowAll, NodeBadgeMode.None] as const) {
|
||||
test(`renders deprecated node with mode=${mode}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
|
||||
mode
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.nodeOps.addNode(DEPRECATED_NODE_TYPE, undefined, {
|
||||
x: 100,
|
||||
y: 100
|
||||
})
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`node-lifecycle-${mode}-${renderer}.png`
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe(`API pricing badge (${renderer})`, { tag }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
await comfyPage.page.evaluate((type) => {
|
||||
const registered = window.LiteGraph!.registered_node_types[type] as {
|
||||
nodeData?: { price_badge?: unknown }
|
||||
}
|
||||
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
|
||||
registered.nodeData.price_badge = {
|
||||
engine: 'jsonata',
|
||||
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
}, API_NODE_TYPE)
|
||||
})
|
||||
|
||||
for (const enabled of [true, false] as const) {
|
||||
test(`renders api node with showApiPricing=${enabled}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeBadge.ShowApiPricing',
|
||||
enabled
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.nodeOps.addNode(API_NODE_TYPE, undefined, {
|
||||
x: 100,
|
||||
y: 100
|
||||
})
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`api-pricing-${enabled ? 'on' : 'off'}-${renderer}.png`
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB |
@@ -21,8 +21,9 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
|
||||
const nodeId = String(loadImageNode.id)
|
||||
const { imagePreview } =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
const imagePreview = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.image-preview')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
|
||||
@@ -43,25 +44,6 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
test('hides mask and download buttons when image is missing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/load_image_widget_missing_file'
|
||||
)
|
||||
|
||||
const { imagePreview } =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.getByTestId('error-loading-image')).toBeVisible()
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
|
||||
await expect(imagePreview.getByLabel('Edit or mask image')).toHaveCount(0)
|
||||
await expect(imagePreview.getByLabel('Download image')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows image context menu options', async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
|
||||
4
packages/ingest-types/src/types.gen.ts
generated
@@ -523,6 +523,10 @@ export type ImportPublishedAssetsRequest = {
|
||||
* IDs of published assets (inputs and models) to import.
|
||||
*/
|
||||
published_asset_ids: Array<string>
|
||||
/**
|
||||
* The share ID of the published workflow these assets belong to. Required for authorization.
|
||||
*/
|
||||
share_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
3
packages/ingest-types/src/zod.gen.ts
generated
@@ -310,7 +310,8 @@ export const zImportPublishedAssetsResponse = z.object({
|
||||
* Request body for importing assets from a published workflow.
|
||||
*/
|
||||
export const zImportPublishedAssetsRequest = z.object({
|
||||
published_asset_ids: z.array(z.string())
|
||||
published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
|
||||
share_id: z.string().min(1).max(64)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -10057,8 +10057,6 @@ export interface components {
|
||||
};
|
||||
progress: number;
|
||||
create_time: number;
|
||||
/** @description Actual credits consumed by the task. Present once status is finalized; 0 for failed tasks. */
|
||||
consumed_credit?: number;
|
||||
};
|
||||
TripoSuccessTask: {
|
||||
/** @enum {integer} */
|
||||
|
||||
@@ -85,11 +85,9 @@ describe('formatUtil', () => {
|
||||
describe('video files', () => {
|
||||
it('should identify video extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('video.mp4')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('apple.m4v')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('clip.webm')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('movie.mov')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('film.avi')).toBe('video')
|
||||
expect(getMediaTypeFromFilename('episode.mkv')).toBe('video')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -581,7 +581,7 @@ const IMAGE_EXTENSIONS = [
|
||||
'tiff',
|
||||
'svg'
|
||||
] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
|
||||
@@ -38,12 +38,8 @@ vi.mock('@/scripts/app', () => {
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
selectItems: vi.fn(),
|
||||
deleteSelected: vi.fn(),
|
||||
finalizeGhostPlacement: vi.fn(),
|
||||
ds: mockDs,
|
||||
setDirty: vi.fn(),
|
||||
state: { ghostNodeId: null as number | null },
|
||||
canvas: { dispatchEvent: vi.fn() }
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -273,7 +269,6 @@ describe('useCoreCommands', () => {
|
||||
|
||||
// Reset app state
|
||||
app.canvas.subgraph = undefined
|
||||
app.canvas.state.ghostNodeId = null
|
||||
|
||||
// Mock settings store
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
|
||||
@@ -612,32 +607,4 @@ describe('useCoreCommands', () => {
|
||||
expect(mockShowAbout).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ghost placement guards', () => {
|
||||
function findCommand(id: string) {
|
||||
return useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
}
|
||||
|
||||
describe('DeleteSelectedItems', () => {
|
||||
it('cancels ghost placement when active and skips deletion', async () => {
|
||||
app.canvas.state.ghostNodeId = 42
|
||||
|
||||
await findCommand('Comfy.Canvas.DeleteSelectedItems').function()
|
||||
|
||||
expect(app.canvas.finalizeGhostPlacement).toHaveBeenCalledWith(true)
|
||||
expect(app.canvas.deleteSelected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes selected items when no ghost is active', async () => {
|
||||
app.canvas.selectedItems = new Set([
|
||||
{}
|
||||
]) as typeof app.canvas.selectedItems
|
||||
|
||||
await findCommand('Comfy.Canvas.DeleteSelectedItems').function()
|
||||
|
||||
expect(app.canvas.finalizeGhostPlacement).not.toHaveBeenCalled()
|
||||
expect(app.canvas.deleteSelected).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -923,10 +923,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Delete Selected Items',
|
||||
versionAdded: '1.10.5',
|
||||
function: () => {
|
||||
if (app.canvas.state.ghostNodeId != null) {
|
||||
app.canvas.finalizeGhostPlacement(true)
|
||||
return
|
||||
}
|
||||
if (app.canvas.selectedItems.size === 0) {
|
||||
app.canvas.canvas.dispatchEvent(
|
||||
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
|
||||
|
||||
@@ -167,52 +167,6 @@ vi.mock('@/scripts/api', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockAppGraph = vi.hoisted(() => ({ value: { _nodes: [] as unknown[] } }))
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get graph() {
|
||||
return mockAppGraph.value
|
||||
},
|
||||
get rootGraph() {
|
||||
return mockAppGraph.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
|
||||
const mockRemoveNodeOutputsForNode = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
removeNodeOutputs: mockRemoveNodeOutputs,
|
||||
removeNodeOutputsForNode: mockRemoveNodeOutputsForNode
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: { captureCanvasState: mockCaptureCanvasState }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockClearNodePreviewCache = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../utils/clearNodePreviewCacheForValues', () => ({
|
||||
clearNodePreviewCacheForValues: mockClearNodePreviewCache,
|
||||
findNodesReferencingValues: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
const mockClearWidgetValues = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../utils/clearDeletedAssetWidgetValues', () => ({
|
||||
clearDeletedAssetWidgetValues: mockClearWidgetValues
|
||||
}))
|
||||
|
||||
const mockMarkMissingMedia = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../utils/markDeletedAssetsAsMissingMedia', () => ({
|
||||
markDeletedAssetsAsMissingMedia: mockMarkMissingMedia
|
||||
}))
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
@@ -839,120 +793,4 @@ describe('useMediaAssetActions', () => {
|
||||
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets — FE-230 preview cache clearing', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('input')
|
||||
mockDeleteAsset.mockReset()
|
||||
mockShowDialog.mockImplementation(
|
||||
(opts: {
|
||||
props: {
|
||||
onConfirm: () => Promise<void> | void
|
||||
}
|
||||
}) => {
|
||||
void opts.props.onConfirm()
|
||||
}
|
||||
)
|
||||
mockAppGraph.value = { _nodes: [] }
|
||||
})
|
||||
|
||||
it('invokes clearNodePreviewCacheForValues with canonical widget-value variants', async () => {
|
||||
mockDeleteAsset.mockResolvedValue(undefined)
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-match',
|
||||
name: 'foo.png',
|
||||
asset_hash: 'abc123.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
const [graphArg, valuesArg, removeArg] =
|
||||
mockClearNodePreviewCache.mock.calls[0]
|
||||
expect(graphArg).toBe(mockAppGraph.value)
|
||||
expect(valuesArg).toEqual(
|
||||
new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
|
||||
)
|
||||
expect(typeof removeArg).toBe('function')
|
||||
|
||||
const sampleNode = { id: 42 }
|
||||
removeArg(sampleNode)
|
||||
expect(mockRemoveNodeOutputsForNode).toHaveBeenCalledWith(sampleNode)
|
||||
// Locator is resolved from the node's own graph, not from the raw id —
|
||||
// covers Load Image / Load Video nodes nested inside subgraphs.
|
||||
expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
|
||||
|
||||
expect(mockClearWidgetValues).toHaveBeenCalledWith(
|
||||
mockAppGraph.value,
|
||||
new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
|
||||
)
|
||||
|
||||
expect(mockMarkMissingMedia).toHaveBeenCalledWith(
|
||||
mockAppGraph.value,
|
||||
new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
|
||||
)
|
||||
|
||||
// markMissing + previewCache must run before widget-value clearing,
|
||||
// otherwise findNodesReferencingValues sees blanked widgets and matches
|
||||
// nothing.
|
||||
const markOrder = mockMarkMissingMedia.mock.invocationCallOrder[0]
|
||||
const cacheOrder = mockClearNodePreviewCache.mock.invocationCallOrder[0]
|
||||
const clearOrder = mockClearWidgetValues.mock.invocationCallOrder[0]
|
||||
expect(markOrder).toBeLessThan(clearOrder)
|
||||
expect(cacheOrder).toBeLessThan(clearOrder)
|
||||
|
||||
// Programmatic widget mutation doesn't go through DOM events, so the
|
||||
// workflow won't be flagged as modified unless we capture explicitly.
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits the [output]-annotated variant for output assets, including subfolder', async () => {
|
||||
mockDeleteAsset.mockResolvedValue(undefined)
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockReturnValue({
|
||||
subfolder: 'outputs/2025'
|
||||
})
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-output',
|
||||
name: 'gen.png',
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
|
||||
expect(valuesArg).toEqual(new Set(['outputs/2025/gen.png [output]']))
|
||||
expect(valuesArg.has('gen.png')).toBe(false)
|
||||
expect(valuesArg.has('gen.png [input]')).toBe(false)
|
||||
})
|
||||
|
||||
it('omits filenames of failed deletions and skips the helper when nothing was deleted', async () => {
|
||||
mockDeleteAsset.mockRejectedValue(new Error('boom'))
|
||||
const actions = useMediaAssetActions()
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-failed',
|
||||
name: 'failed.png',
|
||||
asset_hash: 'failhash.png'
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDeleteAsset).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockClearNodePreviewCache).not.toHaveBeenCalled()
|
||||
expect(mockClearWidgetValues).not.toHaveBeenCalled()
|
||||
expect(mockMarkMissingMedia).not.toHaveBeenCalled()
|
||||
expect(mockCaptureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,22 +7,16 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import { getAssetType } from '../utils/assetTypeUtil'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
|
||||
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
|
||||
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
|
||||
import { getAssetOutputCount } from '../utils/outputAssetUtil'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
@@ -36,35 +30,6 @@ import { assetService } from '../services/assetService'
|
||||
|
||||
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
|
||||
|
||||
/**
|
||||
* Canonical widget-value strings that may reference this asset, scoped by the
|
||||
* asset's source type so basenames cannot cross-match across input/output.
|
||||
*
|
||||
* Output assets emit `<name> [output]` (and the subfolder-prefixed form when
|
||||
* present in metadata). Input/temp assets emit the bare name plus the explicit
|
||||
* annotation. `asset_hash` is included whenever present, since cloud-stored
|
||||
* assets can be referenced by hash.
|
||||
*/
|
||||
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
const variants: string[] = []
|
||||
const type = getAssetType(asset, 'input')
|
||||
const name = asset.name
|
||||
if (name) {
|
||||
if (type === 'output') {
|
||||
const subfolder = getOutputAssetMetadata(asset.user_metadata)?.subfolder
|
||||
const path = subfolder ? `${subfolder}/${name}` : name
|
||||
variants.push(`${path} [output]`)
|
||||
} else if (type === 'temp') {
|
||||
variants.push(`${name} [temp]`)
|
||||
} else {
|
||||
variants.push(name)
|
||||
variants.push(`${name} [input]`)
|
||||
}
|
||||
}
|
||||
if (asset.asset_hash) variants.push(asset.asset_hash)
|
||||
return variants
|
||||
}
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -674,31 +639,6 @@ export function useMediaAssetActions() {
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
const rootGraph = app.rootGraph
|
||||
if (rootGraph) {
|
||||
const deletedValues = new Set<string>()
|
||||
assetArray.forEach((asset, index) => {
|
||||
if (results[index].status !== 'fulfilled') return
|
||||
for (const value of widgetValueVariantsForAsset(asset)) {
|
||||
deletedValues.add(value)
|
||||
}
|
||||
})
|
||||
if (deletedValues.size > 0) {
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
// Order matters: mark + cache-clear both look up nodes by
|
||||
// current widget.value, so they must run before
|
||||
// clearDeletedAssetWidgetValues blanks those values.
|
||||
markDeletedAssetsAsMissingMedia(rootGraph, deletedValues)
|
||||
clearNodePreviewCacheForValues(
|
||||
rootGraph,
|
||||
deletedValues,
|
||||
(node) => nodeOutputStore.removeNodeOutputsForNode(node)
|
||||
)
|
||||
clearDeletedAssetWidgetValues(rootGraph, deletedValues)
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate model caches for affected categories
|
||||
const modelCategories = new Set<string>()
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { clearDeletedAssetWidgetValues } from './clearDeletedAssetWidgetValues'
|
||||
|
||||
type MockWidget = {
|
||||
name: string
|
||||
value: unknown
|
||||
callback?: (value: unknown) => void
|
||||
}
|
||||
type MockNode = {
|
||||
id: number
|
||||
widgets?: MockWidget[]
|
||||
graph?: { setDirtyCanvas: (v: boolean) => void }
|
||||
isSubgraphNode?: () => boolean
|
||||
subgraph?: { nodes: MockNode[] }
|
||||
}
|
||||
|
||||
function makeGraph(nodes: MockNode[]): LGraph {
|
||||
return { nodes } as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('FE-230 clearDeletedAssetWidgetValues', () => {
|
||||
it('clears widget.value and invokes widget.callback so consumers run their own change-handling', () => {
|
||||
const setDirty = vi.fn()
|
||||
const callback = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 1,
|
||||
widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/foo.png [output]'])
|
||||
)
|
||||
|
||||
expect(node.widgets![0].value).toBe('')
|
||||
expect(callback).toHaveBeenCalledWith('')
|
||||
expect(setDirty).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('leaves untouched widgets that do not match deleted values', () => {
|
||||
const matchedCallback = vi.fn()
|
||||
const keptCallback = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 2,
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'outputs/foo.png [output]',
|
||||
callback: matchedCallback
|
||||
},
|
||||
{ name: 'mask', value: 'inputs/keep.png', callback: keptCallback }
|
||||
],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/foo.png [output]'])
|
||||
)
|
||||
|
||||
expect(node.widgets![0].value).toBe('')
|
||||
expect(node.widgets![1].value).toBe('inputs/keep.png')
|
||||
expect(matchedCallback).toHaveBeenCalledWith('')
|
||||
expect(keptCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves nodes alone when none of their widgets reference a deleted value (mask-editor case)', () => {
|
||||
const setDirty = vi.fn()
|
||||
const callback = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 3,
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'clipspace/clipspace-painted-masked-1.png [input]',
|
||||
callback
|
||||
}
|
||||
],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/some-other-asset.png [output]'])
|
||||
)
|
||||
|
||||
expect(node.widgets![0].value).toBe(
|
||||
'clipspace/clipspace-painted-masked-1.png [input]'
|
||||
)
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when the deleted-values set is empty', () => {
|
||||
const setDirty = vi.fn()
|
||||
const callback = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 4,
|
||||
widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(makeGraph([node]), new Set())
|
||||
|
||||
expect(node.widgets![0].value).toBe('outputs/foo.png [output]')
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles widgets without a callback (legacy nodes) without throwing', () => {
|
||||
const node: MockNode = {
|
||||
id: 5,
|
||||
widgets: [{ name: 'image', value: 'outputs/foo.png [output]' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/foo.png [output]'])
|
||||
)
|
||||
).not.toThrow()
|
||||
|
||||
expect(node.widgets![0].value).toBe('')
|
||||
})
|
||||
|
||||
it('clears all matching widgets across multiple nodes', () => {
|
||||
const cbA = vi.fn()
|
||||
const cbB = vi.fn()
|
||||
const nodeA: MockNode = {
|
||||
id: 6,
|
||||
widgets: [
|
||||
{ name: 'image', value: 'outputs/a.png [output]', callback: cbA }
|
||||
],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
const nodeB: MockNode = {
|
||||
id: 7,
|
||||
widgets: [
|
||||
{ name: 'image', value: 'outputs/a.png [output]', callback: cbB }
|
||||
],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([nodeA, nodeB]),
|
||||
new Set(['outputs/a.png [output]'])
|
||||
)
|
||||
|
||||
expect(nodeA.widgets![0].value).toBe('')
|
||||
expect(nodeB.widgets![0].value).toBe('')
|
||||
expect(cbA).toHaveBeenCalledWith('')
|
||||
expect(cbB).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('does not affect nodes without widgets', () => {
|
||||
const node: MockNode = {
|
||||
id: 8,
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
clearDeletedAssetWidgetValues(
|
||||
makeGraph([node]),
|
||||
new Set(['outputs/foo.png [output]'])
|
||||
)
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
|
||||
|
||||
/**
|
||||
* Clear widget values that reference deleted assets so the persisted workflow
|
||||
* JSON stops claiming the deleted asset is in use.
|
||||
*
|
||||
* Without this, after `useMediaAssetActions.deleteAssets` succeeds the
|
||||
* in-memory preview is cleared (`clearNodePreviewCacheForValues`) but the
|
||||
* widget value still points at the deleted asset. On reload the workflow JSON
|
||||
* is restored verbatim and `useImageUploadWidget` re-fetches the URL — for
|
||||
* output assets the file is still served (history-soft-delete), so the
|
||||
* preview re-renders despite the asset being "deleted" everywhere else.
|
||||
*
|
||||
* Mutates `widget.value` (which `LGraphNode.serialize` re-reads to rebuild
|
||||
* `widgets_values`) and invokes `widget.callback` so widgets like Load Image
|
||||
* run their own change-handling (clearing `node.imgs`, calling
|
||||
* `setNodeOutputs`, etc.).
|
||||
*
|
||||
* FE-230 — covers the post-reload case without re-introducing
|
||||
* useMissingMediaPreviewSync, which couldn't distinguish deletion from
|
||||
* verification false-positives (e.g. mask-editor saved values).
|
||||
*/
|
||||
export function clearDeletedAssetWidgetValues(
|
||||
rootGraph: LGraph | Subgraph,
|
||||
deletedValues: ReadonlySet<string>
|
||||
): void {
|
||||
if (deletedValues.size === 0) return
|
||||
for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
|
||||
if (!node.widgets) continue
|
||||
for (const widget of node.widgets) {
|
||||
if (typeof widget.value !== 'string') continue
|
||||
if (!deletedValues.has(widget.value)) continue
|
||||
widget.value = ''
|
||||
widget.callback?.('')
|
||||
}
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
clearNodePreviewCacheForValues,
|
||||
findNodesReferencingValues
|
||||
} from './clearNodePreviewCacheForValues'
|
||||
|
||||
type MockWidget = { name: string; value: unknown }
|
||||
type MockNode = {
|
||||
id: number
|
||||
widgets?: MockWidget[]
|
||||
imgs?: unknown
|
||||
videoContainer?: unknown
|
||||
graph?: { setDirtyCanvas: (v: boolean) => void }
|
||||
isSubgraphNode?: () => boolean
|
||||
subgraph?: { nodes: MockNode[] }
|
||||
}
|
||||
|
||||
function makeGraph(nodes: MockNode[]): LGraph {
|
||||
return { nodes } as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('FE-230 clearNodePreviewCacheForValues', () => {
|
||||
it('clears node.imgs and removes outputs when a widget value matches a deleted value', () => {
|
||||
const setDirty = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 7,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }],
|
||||
imgs: [{ src: 'blob:stale' }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['foo.png']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
expect(setDirty).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('leaves unrelated nodes untouched', () => {
|
||||
const setDirty = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 8,
|
||||
widgets: [{ name: 'image', value: 'unrelated.png' }],
|
||||
imgs: [{ src: 'blob:keep' }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['foo.png']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toEqual([{ src: 'blob:keep' }])
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when the deleted value set is empty', () => {
|
||||
const setDirty = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 9,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }],
|
||||
imgs: [{ src: 'blob:keep' }],
|
||||
graph: { setDirtyCanvas: setDirty }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toEqual([{ src: 'blob:keep' }])
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches the [output]-annotated form for output assets', () => {
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 12,
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }],
|
||||
imgs: [{ src: 'blob:stale' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['foo.png [output]']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('matches the subfolder-prefixed annotated form when provided', () => {
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 13,
|
||||
widgets: [{ name: 'image', value: 'sub/foo.png [output]' }],
|
||||
imgs: [{ src: 'blob:stale' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['sub/foo.png [output]']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('does not cross-match basenames across input/output sources', () => {
|
||||
const remove = vi.fn()
|
||||
const inputNode: MockNode = {
|
||||
id: 1,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }],
|
||||
imgs: [{ src: 'blob:input' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
const outputNode: MockNode = {
|
||||
id: 2,
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }],
|
||||
imgs: [{ src: 'blob:output' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([inputNode, outputNode]),
|
||||
new Set(['foo.png']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(inputNode.imgs).toBeUndefined()
|
||||
expect(outputNode.imgs).toEqual([{ src: 'blob:output' }])
|
||||
expect(remove).toHaveBeenCalledWith(inputNode)
|
||||
expect(remove).not.toHaveBeenCalledWith(outputNode)
|
||||
})
|
||||
|
||||
it('also clears videoContainer for video previews', () => {
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 15,
|
||||
widgets: [{ name: 'video', value: 'clip.mp4' }],
|
||||
videoContainer: { foo: 'bar' },
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['clip.mp4']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.videoContainer).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('matches any widget on the node, not just "image"', () => {
|
||||
const remove = vi.fn()
|
||||
const node: MockNode = {
|
||||
id: 10,
|
||||
widgets: [
|
||||
{ name: 'seed', value: 42 },
|
||||
{ name: 'video', value: 'clip.mp4' }
|
||||
],
|
||||
imgs: [{ src: 'blob:videostale' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([node]),
|
||||
new Set(['clip.mp4']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(node.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('walks subgraph interiors and matches nested nodes', () => {
|
||||
const inner: MockNode = {
|
||||
id: 100,
|
||||
widgets: [{ name: 'image', value: 'nested.png [output]' }],
|
||||
imgs: [{ src: 'blob:nested' }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
const wrapper: MockNode = {
|
||||
id: 50,
|
||||
widgets: [],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: { nodes: [inner] }
|
||||
}
|
||||
const remove = vi.fn()
|
||||
|
||||
clearNodePreviewCacheForValues(
|
||||
makeGraph([wrapper]),
|
||||
new Set(['nested.png [output]']),
|
||||
remove as unknown as (node: LGraphNode) => void
|
||||
)
|
||||
|
||||
expect(inner.imgs).toBeUndefined()
|
||||
expect(remove).toHaveBeenCalledWith(inner)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FE-230 findNodesReferencingValues', () => {
|
||||
it('skips subgraph wrapper nodes (only their interior nodes match)', () => {
|
||||
const inner: MockNode = {
|
||||
id: 100,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }]
|
||||
}
|
||||
const wrapper: MockNode = {
|
||||
id: 50,
|
||||
widgets: [{ name: 'image', value: 'foo.png' }],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: { nodes: [inner] }
|
||||
}
|
||||
|
||||
const matches = findNodesReferencingValues(
|
||||
makeGraph([wrapper]),
|
||||
new Set(['foo.png'])
|
||||
)
|
||||
|
||||
expect(matches).toEqual([inner])
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Clear cached Load Image / Load Video preview state on any node whose widget
|
||||
* value matches one of the given values. Covers:
|
||||
* - the canvas renderer cache (`node.imgs`, `node.videoContainer`)
|
||||
* - the Vue preview source — must be cleared via `removeOutputsForNode`
|
||||
* so the Pinia reactive ref (`nodeOutputStore.nodeOutputs.value`) updates,
|
||||
* not just the legacy `app.nodeOutputs` mirror
|
||||
*
|
||||
* Comparison is full-string against the widget value as stored — callers must
|
||||
* provide the canonical widget-value variants for each deleted asset (e.g.
|
||||
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<asset_hash>`). This
|
||||
* avoids false matches when two distinct assets share a basename across
|
||||
* input/output sources.
|
||||
*
|
||||
* Walks the full graph hierarchy via `collectAllNodes`, so Load Image / Load
|
||||
* Video nodes inside subgraphs are also matched.
|
||||
*
|
||||
* FE-230 — invoked after successful asset deletion so the Load Image / Load
|
||||
* Video node preview does not keep displaying a thumbnail for an asset that
|
||||
* no longer exists.
|
||||
*/
|
||||
export function clearNodePreviewCacheForValues(
|
||||
rootGraph: LGraph | Subgraph,
|
||||
deletedValues: ReadonlySet<string>,
|
||||
removeOutputsForNode: (node: LGraphNode) => void
|
||||
): void {
|
||||
if (deletedValues.size === 0) return
|
||||
for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
|
||||
removeOutputsForNode(node)
|
||||
node.imgs = undefined
|
||||
node.videoContainer = undefined
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the graph hierarchy and yield each leaf node whose widget value matches
|
||||
* one of `deletedValues`. Used by both the preview-clearing path and the
|
||||
* missing-media-marking path so the two stay in lockstep.
|
||||
*
|
||||
* Skips subgraph wrapper nodes — only their interior nodes are inspected.
|
||||
*/
|
||||
export function findNodesReferencingValues(
|
||||
rootGraph: LGraph | Subgraph,
|
||||
deletedValues: ReadonlySet<string>
|
||||
): LGraphNode[] {
|
||||
if (deletedValues.size === 0) return []
|
||||
const matches: LGraphNode[] = []
|
||||
for (const node of collectAllNodes(rootGraph)) {
|
||||
if (!node.widgets?.length) continue
|
||||
if (node.isSubgraphNode?.()) continue
|
||||
const referencesDeleted = node.widgets.some(
|
||||
(w) => typeof w.value === 'string' && deletedValues.has(w.value)
|
||||
)
|
||||
if (referencesDeleted) matches.push(node)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
|
||||
import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
|
||||
scanNodeMediaCandidates: mockScanNodeMediaCandidates
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ currentGraph: null })
|
||||
}))
|
||||
|
||||
function makeGraph(nodes: unknown[]): LGraph {
|
||||
return { nodes } as unknown as LGraph
|
||||
}
|
||||
|
||||
describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockScanNodeMediaCandidates.mockReset()
|
||||
mockScanNodeMediaCandidates.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('adds missing-media candidates only for widgets whose value is in the deleted set', () => {
|
||||
const node = {
|
||||
id: 1,
|
||||
type: 'LoadImage',
|
||||
widgets: [
|
||||
{ name: 'image', value: 'sub/foo.png [output]' },
|
||||
{ name: 'mask', value: 'unrelated.png' }
|
||||
]
|
||||
}
|
||||
mockScanNodeMediaCandidates.mockReturnValue([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'sub/foo.png [output]'
|
||||
},
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'mask',
|
||||
mediaType: 'image',
|
||||
name: 'unrelated.png'
|
||||
}
|
||||
])
|
||||
|
||||
markDeletedAssetsAsMissingMedia(
|
||||
makeGraph([node]),
|
||||
new Set(['sub/foo.png [output]'])
|
||||
)
|
||||
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toEqual([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'sub/foo.png [output]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not cross-match basenames across input/output sources', () => {
|
||||
const inputNode = {
|
||||
id: 2,
|
||||
type: 'LoadImage',
|
||||
widgets: [{ name: 'image', value: 'foo.png' }]
|
||||
}
|
||||
const outputNode = {
|
||||
id: 3,
|
||||
type: 'LoadImage',
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }]
|
||||
}
|
||||
|
||||
markDeletedAssetsAsMissingMedia(
|
||||
makeGraph([inputNode, outputNode]),
|
||||
new Set(['foo.png'])
|
||||
)
|
||||
|
||||
expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
inputNode,
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('skips nodes with NEVER or BYPASS mode', () => {
|
||||
const bypassed = {
|
||||
id: 4,
|
||||
type: 'LoadImage',
|
||||
mode: 4,
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }]
|
||||
}
|
||||
const never = {
|
||||
id: 5,
|
||||
type: 'LoadImage',
|
||||
mode: 2,
|
||||
widgets: [{ name: 'image', value: 'foo.png [output]' }]
|
||||
}
|
||||
|
||||
markDeletedAssetsAsMissingMedia(
|
||||
makeGraph([bypassed, never]),
|
||||
new Set(['foo.png [output]'])
|
||||
)
|
||||
|
||||
expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('walks subgraph interiors and marks nested nodes', () => {
|
||||
const inner = {
|
||||
id: 100,
|
||||
type: 'LoadImage',
|
||||
widgets: [{ name: 'image', value: 'nested.png [output]' }]
|
||||
}
|
||||
const wrapper = {
|
||||
id: 50,
|
||||
widgets: [],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: { nodes: [inner] }
|
||||
}
|
||||
mockScanNodeMediaCandidates.mockReturnValue([
|
||||
{
|
||||
nodeId: '50:100',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'nested.png [output]'
|
||||
}
|
||||
])
|
||||
|
||||
markDeletedAssetsAsMissingMedia(
|
||||
makeGraph([wrapper]),
|
||||
new Set(['nested.png [output]'])
|
||||
)
|
||||
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toEqual([
|
||||
{
|
||||
nodeId: '50:100',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'nested.png [output]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op when no nodes reference any deleted value', () => {
|
||||
const node = {
|
||||
id: 2,
|
||||
type: 'LoadImage',
|
||||
widgets: [{ name: 'image', value: 'kept.png' }]
|
||||
}
|
||||
|
||||
markDeletedAssetsAsMissingMedia(makeGraph([node]), new Set(['gone.png']))
|
||||
|
||||
expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing when the deleted value set is empty', () => {
|
||||
markDeletedAssetsAsMissingMedia(makeGraph([]), new Set())
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
|
||||
import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
|
||||
|
||||
/**
|
||||
* After a successful asset deletion, surface the affected Load Image / Load
|
||||
* Video / Load Audio nodes through the missing-media store. Without this, UI
|
||||
* surfaces that filter against `missingMediaCandidates` (e.g. the Vue node
|
||||
* widget dropdown) keep listing the deleted asset because the verification
|
||||
* pipeline only runs on workflow load — there is no signal that the live
|
||||
* deletion just invalidated some references.
|
||||
*
|
||||
* Walks the full graph hierarchy (including subgraphs) and skips bypassed /
|
||||
* never-execute nodes, mirroring `scanAllMediaCandidates` so the live-delete
|
||||
* path stays in lockstep with the workflow-load verification.
|
||||
*
|
||||
* Comparison is full-string against the widget value, so two distinct assets
|
||||
* that share a basename across input/output sources do not cross-match.
|
||||
*/
|
||||
export function markDeletedAssetsAsMissingMedia(
|
||||
rootGraph: LGraph,
|
||||
deletedValues: ReadonlySet<string>
|
||||
): void {
|
||||
if (deletedValues.size === 0) return
|
||||
|
||||
const matchedNodes = findNodesReferencingValues(rootGraph, deletedValues)
|
||||
if (!matchedNodes.length) return
|
||||
|
||||
const candidates: MissingMediaCandidate[] = []
|
||||
for (const node of matchedNodes) {
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
|
||||
if (!deletedValues.has(candidate.name)) continue
|
||||
candidates.push({ ...candidate, isMissing: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length) {
|
||||
useMissingMediaStore().addMissingMedia(candidates)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
dialogStack: []
|
||||
}))
|
||||
}))
|
||||
|
||||
function createKeyboardEvent(
|
||||
key: string,
|
||||
options: {
|
||||
target?: Element
|
||||
ctrlKey?: boolean
|
||||
metaKey?: boolean
|
||||
shiftKey?: boolean
|
||||
altKey?: boolean
|
||||
} = {}
|
||||
): KeyboardEvent {
|
||||
const { target = document.body, ...modifiers } = options
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
code: key === 'Enter' ? 'Enter' : key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifiers
|
||||
})
|
||||
event.preventDefault = vi.fn()
|
||||
event.stopPropagation = vi.fn()
|
||||
event.composedPath = vi.fn(() => [target])
|
||||
return event
|
||||
}
|
||||
|
||||
describe('keybindingService - event propagation', () => {
|
||||
let keybindingService: ReturnType<typeof useKeybindingService>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
commandStore.execute = vi.fn()
|
||||
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: []
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
keybindingService.registerCoreKeybindings()
|
||||
})
|
||||
|
||||
it('stops propagation when Ctrl+Enter fires with a non-input element focused', async () => {
|
||||
// Simulates a dropdown (combobox div) being focused when Ctrl+Enter is pressed.
|
||||
// Without stopPropagation the event reaches the dropdown handler and expands it.
|
||||
const dropdown = document.createElement('div')
|
||||
dropdown.setAttribute('role', 'combobox')
|
||||
|
||||
const event = createKeyboardEvent('Enter', {
|
||||
ctrlKey: true,
|
||||
target: dropdown
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.QueuePrompt',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(event.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stop propagation when no keybinding matches', async () => {
|
||||
const event = createKeyboardEvent('F13') // no binding for this key
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(event.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stop propagation when key is reserved by text input and target is textarea', async () => {
|
||||
const textarea = document.createElement('textarea')
|
||||
const event = createKeyboardEvent('Enter', { target: textarea })
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(event.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -56,7 +56,6 @@ export function useKeybindingService() {
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const runCommandIds = new Set([
|
||||
'Comfy.QueuePrompt',
|
||||
'Comfy.QueuePromptFront',
|
||||
|
||||
@@ -134,29 +134,6 @@ describe('ImagePreview', () => {
|
||||
screen.getByRole('button', { name: 'Edit or mask image' })
|
||||
})
|
||||
|
||||
it('hides mask and download buttons when image fails to load', async () => {
|
||||
renderImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Edit or mask image' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Download image' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
await fireEvent.error(screen.getByTestId('main-image'))
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Edit or mask image' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Download image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles download button click', async () => {
|
||||
renderImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
v-if="!hasMultipleImages && !imageError"
|
||||
v-if="!hasMultipleImages"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.editOrMaskImage')"
|
||||
:aria-label="$t('g.editOrMaskImage')"
|
||||
@@ -91,7 +91,6 @@
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
v-if="!imageError"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.downloadImage')"
|
||||
:aria-label="$t('g.downloadImage')"
|
||||
|
||||
@@ -871,136 +871,4 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(selectedSet.value.has('missing-nonexistent.png')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FE-230 missing-media filtering', () => {
|
||||
it('drops input items whose name is in the missing-media store', async () => {
|
||||
const { useMissingMediaStore } =
|
||||
await import('@/platform/missingMedia/missingMediaStore')
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo_abc.jpg',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).not.toContain('photo_abc.jpg')
|
||||
expect(names).toContain('img_001.png')
|
||||
})
|
||||
|
||||
it('drops output items whose annotated path is in the missing-media store', async () => {
|
||||
mockMediaAssets = createMockMediaAssets()
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'gone.png',
|
||||
size: 0,
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00Z'
|
||||
} as AssetItem,
|
||||
{
|
||||
id: 'a2',
|
||||
name: 'kept.png',
|
||||
size: 0,
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00Z'
|
||||
} as AssetItem
|
||||
]
|
||||
|
||||
const { useMissingMediaStore } =
|
||||
await import('@/platform/missingMedia/missingMediaStore')
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
{
|
||||
nodeId: '7',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'gone.png [output]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => [],
|
||||
outputMediaAssets: mockMediaAssets
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).not.toContain('gone.png [output]')
|
||||
expect(names).toContain('kept.png [output]')
|
||||
})
|
||||
|
||||
it('does not cross-match basenames across input and output sources', async () => {
|
||||
mockMediaAssets = createMockMediaAssets()
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'photo_abc.jpg',
|
||||
size: 0,
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00Z'
|
||||
} as AssetItem
|
||||
]
|
||||
|
||||
const { useMissingMediaStore } =
|
||||
await import('@/platform/missingMedia/missingMediaStore')
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo_abc.jpg',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems } = useWidgetSelectItems(
|
||||
createDefaultOptions({ outputMediaAssets: mockMediaAssets })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).not.toContain('photo_abc.jpg')
|
||||
expect(names).toContain('photo_abc.jpg [output]')
|
||||
})
|
||||
|
||||
it('does not surface a missing-value placeholder when the modelValue is confirmed missing', async () => {
|
||||
const modelValue = ref<string | undefined>('gone.png [output]')
|
||||
|
||||
const { useMissingMediaStore } =
|
||||
await import('@/platform/missingMedia/missingMediaStore')
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
{
|
||||
nodeId: '7',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'gone.png [output]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
|
||||
const { dropdownItems, selectedSet } = useWidgetSelectItems(
|
||||
createDefaultOptions({ modelValue, values: () => [] })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
const names = dropdownItems.value.map((i) => i.name)
|
||||
expect(names).not.toContain('gone.png [output]')
|
||||
expect(selectedSet.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import {
|
||||
filterItemByBaseModels,
|
||||
filterItemByOwnership
|
||||
@@ -73,14 +72,6 @@ interface UseWidgetSelectItemsOptions {
|
||||
export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
const { modelValue, outputMediaAssets, assetData } = options
|
||||
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const missingMediaValues = computed<ReadonlySet<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
missingMediaStore.missingMediaCandidates?.map((c) => c.name) ?? []
|
||||
)
|
||||
)
|
||||
|
||||
const filterSelected = ref('all')
|
||||
const filterOptions = computed<FilterOption[]>(() => {
|
||||
const isAsset = toValue(options.isAssetMode)
|
||||
@@ -162,15 +153,12 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
|
||||
const labelFn = toValue(options.getOptionLabel)
|
||||
const kind = toValue(options.assetKind)
|
||||
const missing = missingMediaValues.value
|
||||
return values
|
||||
.filter((value) => !missing.has(String(value)))
|
||||
.map((value, index) => ({
|
||||
id: `input-${index}`,
|
||||
preview_url: getMediaUrl(String(value), 'input', kind),
|
||||
name: String(value),
|
||||
label: getDisplayLabel(String(value), labelFn)
|
||||
}))
|
||||
return values.map((value, index) => ({
|
||||
id: `input-${index}`,
|
||||
preview_url: getMediaUrl(String(value), 'input', kind),
|
||||
name: String(value),
|
||||
label: getDisplayLabel(String(value), labelFn)
|
||||
}))
|
||||
})
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
@@ -188,7 +176,6 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
return resolved ?? [asset]
|
||||
})
|
||||
|
||||
const missing = missingMediaValues.value
|
||||
for (const asset of assets) {
|
||||
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
|
||||
if (seen.has(asset.id)) continue
|
||||
@@ -201,7 +188,6 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
? `${subfolder}/${asset.name}`
|
||||
: asset.name
|
||||
const annotatedPath = `${pathWithSubfolder} [output]`
|
||||
if (missing.has(annotatedPath)) continue
|
||||
const displayLabel = `${getAssetDisplayFilename(asset)} [output]`
|
||||
items.push({
|
||||
id: `output-${asset.id}`,
|
||||
@@ -223,8 +209,6 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
const labelFn = toValue(options.getOptionLabel)
|
||||
const kind = toValue(options.assetKind)
|
||||
|
||||
if (missingMediaValues.value.has(currentValue)) return undefined
|
||||
|
||||
if (toValue(options.isAssetMode) && assetData) {
|
||||
const existsInAssets = assetData.assets.value.some(
|
||||
(asset) => getAssetFilename(asset) === currentValue
|
||||
|
||||
@@ -26,6 +26,9 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
//@ts-expect-error Define global for the test
|
||||
global.__COMFYUI_FRONTEND_VERSION__ = '1.24.0'
|
||||
|
||||
import { useNewUserService } from '@/services/useNewUserService'
|
||||
|
||||
describe('useNewUserService', () => {
|
||||
@@ -117,73 +120,6 @@ describe('useNewUserService', () => {
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when V1 draft store keys exist', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.Drafts') return '{}'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when V1 draft order key exists', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.DraftOrder') return '[]'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when V2 draft index has entries', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.DraftIndex.v2:personal')
|
||||
return '{"v":2,"updatedAt":1,"order":["abc"],"entries":{"abc":{"path":"workflows/Untitled.json","name":"Untitled","isTemporary":true,"updatedAt":1}}}'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify new user when V2 draft index exists but is empty', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.DraftIndex.v2:personal')
|
||||
return '{"v":2,"updatedAt":1,"order":[],"entries":{}}'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify new user when V2 draft index is malformed', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.DraftIndex.v2:personal') return 'not json'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser()
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify new user when tutorial is explicitly false', async () => {
|
||||
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
|
||||
@@ -2,24 +2,6 @@ import { ref, shallowRef } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
function hasV2DraftHistory(raw: string | null): boolean {
|
||||
if (!raw) return false
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
order?: unknown
|
||||
entries?: unknown
|
||||
}
|
||||
const orderLength = Array.isArray(parsed.order) ? parsed.order.length : 0
|
||||
const entriesCount =
|
||||
parsed.entries && typeof parsed.entries === 'object'
|
||||
? Object.keys(parsed.entries as Record<string, unknown>).length
|
||||
: 0
|
||||
return orderLength > 0 || entriesCount > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function _useNewUserService() {
|
||||
const settingStore = useSettingStore()
|
||||
const pendingCallbacks = shallowRef<Array<() => Promise<void>>>([])
|
||||
@@ -36,32 +18,12 @@ function _useNewUserService() {
|
||||
const isNewUserSettings =
|
||||
Object.keys(settingStore.settingValues).length === 0 ||
|
||||
!settingStore.get('Comfy.TutorialCompleted')
|
||||
|
||||
// Legacy keys (pre-V1 and V1 persistence)
|
||||
const hasNoLegacyWorkflow =
|
||||
!localStorage.getItem('workflow') &&
|
||||
!localStorage.getItem('Comfy.PreviousWorkflow')
|
||||
|
||||
// V1 draft store keys
|
||||
const hasNoV1Drafts =
|
||||
!localStorage.getItem('Comfy.Workflow.Drafts') &&
|
||||
!localStorage.getItem('Comfy.Workflow.DraftOrder')
|
||||
|
||||
// V2 draft index key (scoped to personal workspace; cloud workspace id
|
||||
// comes from sessionStorage which may not be set yet at this point).
|
||||
// Check for actual draft history rather than key existence: an empty
|
||||
// index is written by `migrateV1toV2()` for genuine new users during
|
||||
// startup, so key presence alone is not evidence of prior usage.
|
||||
const hasNoV2DraftIndex = !hasV2DraftHistory(
|
||||
localStorage.getItem('Comfy.Workflow.DraftIndex.v2:personal')
|
||||
const hasNoWorkflow = !localStorage.getItem('workflow')
|
||||
const hasNoPreviousWorkflow = !localStorage.getItem(
|
||||
'Comfy.PreviousWorkflow'
|
||||
)
|
||||
|
||||
return (
|
||||
isNewUserSettings &&
|
||||
hasNoLegacyWorkflow &&
|
||||
hasNoV1Drafts &&
|
||||
hasNoV2DraftIndex
|
||||
)
|
||||
return isNewUserSettings && hasNoWorkflow && hasNoPreviousWorkflow
|
||||
}
|
||||
|
||||
async function registerInitCallback(callback: () => Promise<void>) {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
|
||||
|
||||
const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({ removeNodeOutputs: mockRemoveNodeOutputs })
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
isGraphReady: true,
|
||||
rootGraph: { nodes: [], _nodes: [] } as unknown as LGraph
|
||||
}))
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/utils/graphTraversalUtil', async () => {
|
||||
const actual = await vi.importActual<typeof GraphTraversalUtil>(
|
||||
'@/utils/graphTraversalUtil'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
getNodeByExecutionId: mockGetNodeByExecutionId
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
|
||||
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({ get: vi.fn(() => false) }))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({ get: vi.fn(() => false) }))
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/missingModel/composables/useMissingModelInteractions',
|
||||
() => ({ clearMissingModelState: vi.fn() })
|
||||
)
|
||||
|
||||
import { useExecutionErrorStore } from './executionErrorStore'
|
||||
|
||||
function makeNodeWithPreview(id: number): LGraphNode {
|
||||
return {
|
||||
id,
|
||||
imgs: [{ src: 'blob:mask-edited' }],
|
||||
videoContainer: undefined,
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('FE-230 regression — workflow-load missing-media flagging must not wipe node previews', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockApp.isGraphReady = true
|
||||
mockApp.rootGraph = { nodes: [], _nodes: [] } as unknown as LGraph
|
||||
mockRemoveNodeOutputs.mockReset()
|
||||
mockGetNodeByExecutionId.mockReset()
|
||||
})
|
||||
|
||||
it('does not clear node.imgs when verification flags a Load Image as missing on workflow load (e.g. mask-editor saved value)', async () => {
|
||||
const node = makeNodeWithPreview(42)
|
||||
mockGetNodeByExecutionId.mockReturnValue(node)
|
||||
|
||||
useExecutionErrorStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
|
||||
missingMediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: '42',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'clipspace/clipspace-painted-masked-1.png [input]',
|
||||
isMissing: true
|
||||
}
|
||||
])
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(node.imgs).toEqual([{ src: 'blob:mask-edited' }])
|
||||
expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -367,11 +367,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function removeOutputsByLocatorId(nodeLocatorId: NodeLocatorId) {
|
||||
/**
|
||||
* Remove node outputs for a specific node
|
||||
* Clears both outputs and preview images
|
||||
*/
|
||||
function removeNodeOutputs(nodeId: number | string) {
|
||||
const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
|
||||
if (!nodeLocatorId) return false
|
||||
|
||||
// Clear from app.nodeOutputs
|
||||
const hadOutputs = !!app.nodeOutputs[nodeLocatorId]
|
||||
delete app.nodeOutputs[nodeLocatorId]
|
||||
|
||||
// Clear from reactive state
|
||||
delete nodeOutputs.value[nodeLocatorId]
|
||||
|
||||
// Clear preview images
|
||||
if (app.nodePreviewImages[nodeLocatorId]) {
|
||||
const previews = app.nodePreviewImages[nodeLocatorId]
|
||||
if (previews?.[Symbol.iterator]) {
|
||||
@@ -386,22 +397,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
return hadOutputs
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove node outputs for a specific node
|
||||
* Clears both outputs and preview images
|
||||
*/
|
||||
function removeNodeOutputs(nodeId: number | string) {
|
||||
const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
|
||||
if (!nodeLocatorId) return false
|
||||
return removeOutputsByLocatorId(nodeLocatorId)
|
||||
}
|
||||
|
||||
// Resolves the locator from the node's own graph, so interior subgraph nodes
|
||||
// are addressed correctly even when the user has a different graph active.
|
||||
function removeNodeOutputsForNode(node: LGraphNode) {
|
||||
return removeOutputsByLocatorId(nodeToNodeLocatorId(node))
|
||||
}
|
||||
|
||||
function snapshotOutputs(): Record<string, ExecutedWsMessage['output']> {
|
||||
return clone(app.nodeOutputs)
|
||||
}
|
||||
@@ -498,7 +493,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
revokeAllPreviews,
|
||||
revokeSubgraphPreviews,
|
||||
removeNodeOutputs,
|
||||
removeNodeOutputsForNode,
|
||||
snapshotOutputs,
|
||||
restoreOutputs,
|
||||
resetAllOutputsAndPreviews,
|
||||
|
||||
@@ -276,9 +276,7 @@ onBeforeUnmount(() => {
|
||||
executionStore.unbindExecutionEvents()
|
||||
})
|
||||
|
||||
useEventListener(window, 'keydown', useKeybindingService().keybindHandler, {
|
||||
capture: true
|
||||
})
|
||||
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
|
||||
|
||||
const { wrapWithErrorHandling, wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
|
||||