Compare commits

..

19 Commits

Author SHA1 Message Date
pythongosssss
ba22334759 add image compare support 2026-03-30 10:46:58 -07:00
Alexander Brown
191f4128af Merge branch 'main' into app-mode/fix/image-compare 2026-03-16 13:31:21 -07:00
Alexander Brown
f0b91bdcfa fix: resolve all lint warnings (#9972)
Resolve all lint warnings (3 oxlint + 1 eslint).

## Changes

- Replace `it.todo` with `it.skip` in subgraph tests (`warn-todo`)
- Move `vi.mock` to top-level in `firebaseAuthStore.test.ts`
(`hoisted-apis-on-top`)
- Rename `DOMPurify` default import to `dompurify`
(`no-named-as-default`)

---

### The Villager Who Ignored the Warnings

Once there lived a villager whose compiler whispered of lint. "They are
only *warnings*," she said, and went about her day. One warning became
three. Three became thirty. The yellow text grew like ivy across the
terminal, until no one could tell the warnings from the errors. One
morning a real error appeared — a misplaced mock, a shadowed import —
but nobody noticed, for the village had long since learned to stop
reading. The build shipped. The users wept. And the warning, faithful to
the last, sat quietly in the log where it had always been.

*Moral: Today's warning is tomorrow's incident report.*

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9972-fix-resolve-all-lint-warnings-3246d73d3650810a89cde5d05e79d948)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 13:27:02 -07:00
Christian Byrne
46dad2e077 ops: restrict PyPI publishing to bi-weekly ComfyUI releases (#9948)
## Summary

Restrict PyPI publishing of `comfyui-frontend-package` to bi-weekly
ComfyUI release cycles only, instead of every nightly version bump.

## Changes

- **What**: Move `publish_pypi` job from `release-draft-create.yaml` to
`release-biweekly-comfyui.yaml`
1. Removed `publish_pypi` job from `release-draft-create.yaml` (no
longer publishes on every merged Release PR)
2. Added `publish-pypi` job to `release-biweekly-comfyui.yaml` with tag
polling, build, publish, and PyPI availability confirmation
3. Gated `create-comfyui-pr` on `publish-pypi` success so the ComfyUI
requirements bump PR is only created after the package is confirmed
available
4. Updated ComfyUI PR body to confirm PyPI availability instead of
warning about a pending release PR
- **Breaking**: None — nightly releases still create GitHub releases and
publish npm types; only PyPI publishing timing changes
- **Dependencies**: None

## Review Focus

- The `publish-pypi` job uses `if: always() &&
needs.resolve-version.result == 'success'` to run even when
`trigger-release-if-needed` is skipped (tag already exists)
- Tag polling (30min timeout) waits for the version bump PR to be merged
before building from the tagged commit
- PyPI propagation polling (15min timeout) confirms the package is
installable before creating the ComfyUI PR

Fixes COM-16778

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9948-ops-restrict-PyPI-publishing-to-bi-weekly-ComfyUI-releases-3246d73d36508198b00fcc247ac5b58c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-16 13:11:15 -07:00
Johnpaul Chiwetelu
f68d8365a6 chore: enable auto-merge on backport PRs (#10108)
## Summary
- Adds `gh pr merge --auto --squash` after backport PR creation in the
backport workflow, so backport PRs merge automatically once checks pass
- Uses `|| echo "::warning::..."` fallback to avoid failing the workflow
if auto-merge can't be enabled (e.g. repo setting not configured)

## Test plan
- [ ] Trigger backport workflow on a test PR with `needs-backport` label
- [ ] Verify auto-merge is enabled on the created backport PR

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10108-chore-enable-auto-merge-on-backport-PRs-3256d73d3650814eb6e5fb2bdf3c5ec7)
by [Unito](https://www.unito.io)
2026-03-16 12:57:48 -07:00
Benjamin Lu
b3ebf1418a fix: add background to running job rows (#9748)
## Summary

Add the missing surface fill for running job rows in the queue progress
panel using the existing semantic background token.

## Changes

- Apply `bg-secondary-background` to running rows in `JobAssetsList`
while preserving the existing hover state.
- Reuse the existing `secondary-background` /
`secondary-background-hover` tokens instead of introducing new
single-use job-card tokens.

## Validation

- `pnpm test:unit -- src/components/queue/job/JobAssetsList.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm format:check`


https://github.com/user-attachments/assets/a926ede6-99e8-4f5a-b164-f9cf3cd124a7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9748-fix-add-background-to-running-job-rows-3206d73d365081519559dfe3a9cf2037)
by [Unito](https://www.unito.io)
2026-03-16 12:10:55 -07:00
pythongosssss
fb756b41c8 feat: App mode - allow resizing of textarea and image previews (#9792)
## Summary

Allows users to resize images/text areas

## Changes

- **What**: Add resize-y to the elements and prevent upload when
clicking on the resize handle

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9792-feat-App-mode-allow-resizing-of-textarea-and-image-previews-3216d73d36508127b022f0cfbcab3a3a)
by [Unito](https://www.unito.io)
2026-03-16 12:06:57 -07:00
Benjamin Lu
45b3e0ec64 fix: restore queue job details popover (#9549)
## Summary

Restore the existing job details popover in the expanded queue overlay
after the list row UI moved to `AssetsListItem`.

## Changes

- **What**: Add delayed hover-driven job details popover support back to
`JobAssetsList`, including teleported positioning, row-to-popover hover
handoff, and focused component coverage for the timing behavior.

## Review Focus

Please verify the hover transition between the queue row and the
teleported popover, especially around positioning and whether the
popover remains reachable in the top menu queue overlay.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9549-fix-restore-queue-job-details-popover-31d6d73d3650815eaf45dcc2a14d3dee)
by [Unito](https://www.unito.io)
2026-03-16 09:20:49 -07:00
Johnpaul Chiwetelu
ed5e0a0b51 chore: replace team CODEOWNERS with external PR review workflow (#10104)
## Summary

Remove team assignments from CODEOWNERS to reduce notification noise for
internal PRs. Add a workflow that requests team review only when
external contributors open PRs.

## Changes

- **What**: Strip `@Comfy-org/comfy_frontend_devs` and
`@Comfy-Org/comfy_maintainer` from all CODEOWNERS entries (keep
individual user assignments). Add `pr-request-team-review.yaml` workflow
that uses `pull_request_target` to request team review for
non-collaborator PRs.
- **Dependencies**: None

## Review Focus

- The workflow uses `pull_request_target` but does not check out or
execute any untrusted code — it only runs `gh pr edit --add-reviewer`.
- The `author_association` check excludes OWNER, MEMBER, and
COLLABORATOR — internal PRs will not trigger team review requests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10104-chore-replace-team-CODEOWNERS-with-external-PR-review-workflow-3256d73d3650813b887ac16b5e97b4c4)
by [Unito](https://www.unito.io)
2026-03-16 15:49:58 +01:00
bymyself
1b8a3fb734 refactor: remove redundant animated filtering from TaskItemImpl
animated is already excluded by METADATA_KEYS in parseNodeOutput, so the
_.omit call in the constructor was redundant. Also removes unused
es-toolkit/compat import.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9622#discussion_r2925170222
2026-03-12 08:00:08 -07:00
bymyself
7535857276 fix: accept items with filename but no subfolder in isResultItem
Restores compatibility with custom nodes that only send filename without
subfolder. The ResultItemImpl constructor already falls back to ''.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9622#discussion_r2925170216
2026-03-12 08:00:02 -07:00
bymyself
85c6740928 fix: cleanup WebGL resources before reinitializing viewer on model switch
Address CodeRabbit review feedback:
- Call viewer.cleanup() before initializeStandaloneViewer in watcher to prevent
  WebGL context leaks on 3D-to-3D model switches
- Add { flush: 'post' } to ensure DOM is ready before initialization
- Assert cleanup is called before reinitialization in test
- Unmount wrappers in all tests to prevent cross-test coupling
2026-03-12 03:07:19 -07:00
bymyself
235a7e286c fix: update text-only preview_output test for stricter validation
The centralized parseTaskOutput correctly rejects preview_output items
that lack required filename/subfolder fields. A text-only preview_output
with no filename should produce zero flatOutputs, not one with empty
filename.
2026-03-12 02:45:31 -07:00
bymyself
b32429293f test: add modelUrl prop change test for Preview3d
Verifies that changing modelUrl on an existing Preview3d instance
triggers reinitialization via the watch. Addresses CodeRabbit
review comment on PR #9622.
2026-03-12 02:45:31 -07:00
bymyself
5af47b8c01 refactor: centralize NodeExecutionOutput → ResultItemImpl parsing
Extract shared parseNodeOutput/parseTaskOutput utility to eliminate
three independent copies of the same conversion with inconsistent
validation:
- flattenNodeOutput.ts (strict, required filename+subfolder)
- jobOutputCache.ts (weak, any single field sufficient)
- queueStore.ts (no validation, cast as ResultItem[])

All three now delegate to a single isResultItem guard that requires
filename and subfolder as strings and validates type via the Zod
resultItemType enum. Also excludes both 'animated' and 'text'
metadata keys consistently.

Addresses review feedback from DrJKL on PR #9622.
2026-03-12 02:45:31 -07:00
bymyself
2c694d9fc3 docs: add ADR-0007 for NodeExecutionOutput passthrough schema design
Documents why zOutputs uses .passthrough() instead of .catchall(),
the TypeScript index signature limitation that prevents catchall,
and the decision to centralize ResultItem parsing.
2026-03-12 02:45:31 -07:00
bymyself
0217e061b7 fix: 3D asset disappears when switching to image output in app mode
Add onUnmounted cleanup to Preview3d to release WebGL context when
the component is destroyed by Vue's v-if chain.
2026-03-12 02:45:31 -07:00
bymyself
b077a658f8 fix: 3D asset disappears when switching to image output in app mode
- Add cleanup on unmount to prevent WebGL context leaks
- Add cleanup before re-init to prevent stacked Load3d instances
- Use flush:'post' watch to ensure DOM is ready before init
- Add :key on Preview3d for fresh instance on URL change
2026-03-12 02:45:31 -07:00
bymyself
b21512303e fix: support non-standard output keys in app mode preview
Replace hardcoded allowlist of 5 output keys (images, audio, video,
gifs, 3d) with dynamic iteration over all output entries, validating
each item with isResultItemLike. Nodes like ImageCompare that output
non-standard keys (a_images, b_images) now preview correctly.
2026-03-12 02:45:31 -07:00
36 changed files with 1749 additions and 333 deletions

View File

@@ -348,6 +348,8 @@ jobs:
PR_NUM=$(echo "${PR_URL}" | grep -o '[0-9]*$')
if [ -n "${PR_NUM}" ]; then
gh pr merge "${PR_NUM}" --auto --squash --repo "${{ github.repository }}" \
|| echo "::warning::Failed to enable auto-merge for PR #${PR_NUM}"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Successfully backported to #${PR_NUM}"
fi
else

View File

@@ -0,0 +1,24 @@
# Request team review for PRs from external contributors.
name: PR:Request Team Review
on:
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
jobs:
request-review:
if: >-
!contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'),
github.event.pull_request.author_association)
runs-on: ubuntu-latest
steps:
- name: Request team review
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr edit ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--add-reviewer Comfy-org/comfy_frontend_devs

View File

@@ -162,9 +162,132 @@ jobs:
echo "- Target version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
echo "- [View workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)" >> $GITHUB_STEP_SUMMARY
publish-pypi:
needs: [resolve-version, trigger-release-if-needed]
if: >
always() &&
needs.resolve-version.result == 'success' &&
(needs.trigger-release-if-needed.result == 'success' ||
needs.trigger-release-if-needed.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Wait for release PR to be created and merged
if: needs.trigger-release-if-needed.result == 'success'
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
run: |
set -euo pipefail
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
TARGET_BRANCH="${{ needs.resolve-version.outputs.target_branch }}"
echo "Waiting for version bump PR for v${TARGET_VERSION} on ${TARGET_BRANCH} to be merged..."
# Poll for up to 30 minutes (a human or automation needs to merge the version bump PR)
for i in $(seq 1 60); do
# Check if the tag exists (release-draft-create creates a tag on merge)
if gh api "repos/Comfy-Org/ComfyUI_frontend/git/ref/tags/v${TARGET_VERSION}" --silent 2>/dev/null; then
echo "✅ Tag v${TARGET_VERSION} found — release PR has been merged"
exit 0
fi
echo "Attempt $i/60: Tag v${TARGET_VERSION} not found yet, waiting 30s..."
sleep 30
done
echo "❌ Timed out waiting for tag v${TARGET_VERSION}"
exit 1
- name: Checkout code at target version
uses: actions/checkout@v6
with:
ref: v${{ needs.resolve-version.outputs.target_version }}
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Build project
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
run: |
pnpm install --frozen-lockfile
pnpm build
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install build dependencies
run: python -m pip install build
- name: Build and publish PyPI package
run: |
set -euo pipefail
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
- name: Build pypi package
run: python -m build
working-directory: comfyui_frontend_package
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.resolve-version.outputs.target_version }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist
- name: Wait for PyPI propagation
run: |
set -euo pipefail
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
PACKAGE="comfyui-frontend-package"
echo "Waiting for ${PACKAGE}==${TARGET_VERSION} to be available on PyPI..."
# Wait up to 15 minutes (polling every 30 seconds)
for i in $(seq 1 30); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json")
if [ "$HTTP_CODE" = "200" ]; then
echo "✅ ${PACKAGE}==${TARGET_VERSION} is available on PyPI"
exit 0
fi
echo "Attempt $i/30: PyPI returned HTTP ${HTTP_CODE}, waiting 30s..."
sleep 30
done
echo "❌ Timed out waiting for ${PACKAGE}==${TARGET_VERSION} on PyPI"
exit 1
- name: Summary
run: |
echo "## PyPI Publishing" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Package: comfyui-frontend-package" >> $GITHUB_STEP_SUMMARY
echo "- Version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
echo "- Status: ✅ Published and confirmed available" >> $GITHUB_STEP_SUMMARY
create-comfyui-pr:
needs: [check-release-week, resolve-version, trigger-release-if-needed]
if: always() && needs.resolve-version.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch')
needs:
[
check-release-week,
resolve-version,
trigger-release-if-needed,
publish-pypi
]
if: always() && needs.resolve-version.result == 'success' && needs.publish-pypi.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
steps:
@@ -236,11 +359,8 @@ jobs:
EOF
)
# Add release PR note if release was triggered
if [ "${{ needs.resolve-version.outputs.needs_release }}" = "true" ]; then
RELEASE_NOTE="⚠️ **Release PR must be merged first** - check [release workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)"
BODY=$''"${RELEASE_NOTE}"$'\n\n'"${BODY}"
fi
PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified."
BODY=$''"${PYPI_NOTE}"$'\n\n'"${BODY}"
# Save to file for later use
printf '%s\n' "$BODY" > pr-body.txt
@@ -307,7 +427,11 @@ jobs:
fi
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
echo "PR already exists (#${EXISTING_PR}), updating branch will update the PR"
echo "PR already exists (#${EXISTING_PR}), refreshing title/body"
gh pr edit "$EXISTING_PR" \
--repo Comfy-Org/ComfyUI \
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
--body-file ../pr-body.txt
else
echo "Failed to create PR and no existing PR found"
exit 1

View File

@@ -99,37 +99,6 @@ jobs:
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true
publish_pypi:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Download dist artifact
uses: actions/download-artifact@v7
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install build dependencies
run: python -m pip install build
- name: Setup pypi package
run: |
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
- name: Build pypi package
run: python -m build
working-directory: comfyui_frontend_package
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist
publish_types:
needs: build
uses: ./.github/workflows/release-npm-types.yaml
@@ -142,7 +111,6 @@ jobs:
name: Comment Release Summary
needs:
- draft_release
- publish_pypi
- publish_types
if: success()
runs-on: ubuntu-latest

View File

@@ -1,61 +1,55 @@
# Global Ownership
* @Comfy-org/comfy_frontend_devs
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/stores/electronDownloadStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/extensions/core/electronAdapter.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/vite.electron.config.mts @benceruleanlu @Comfy-org/comfy_frontend_devs
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
# Common UI Components
/src/components/chip/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/card/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/button/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/input/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
# Topbar
/src/components/topbar/ @pythongosssss @Comfy-org/comfy_frontend_devs
/src/components/topbar/ @pythongosssss
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss @Comfy-org/comfy_frontend_devs
/src/renderer/core/thumbnail/ @pythongosssss
# Legacy UI
/scripts/ui/ @pythongosssss @Comfy-org/comfy_frontend_devs
/scripts/ui/ @pythongosssss
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-org/comfy_frontend_devs
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/services/nodeHelpService.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery @Comfy-org/comfy_frontend_devs
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery @Comfy-org/comfy_frontend_devs
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
# 3D
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-org/comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @Comfy-org/comfy_frontend_devs
/src/extensions/core/load3d.ts @jtydhr88
/src/components/load3d/ @jtydhr88
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata @Comfy-org/comfy_frontend_devs
# Translations
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -0,0 +1,71 @@
# 7. NodeExecutionOutput Passthrough Schema Design
Date: 2026-03-11
## Status
Accepted
## Context
`NodeExecutionOutput` represents the output data from a ComfyUI node execution. The backend API is intentionally open-ended: custom nodes can output any key (`gifs`, `3d`, `meshes`, `point_clouds`, etc.) alongside the well-known keys (`images`, `audio`, `video`, `animated`, `text`).
The Zod schema uses `.passthrough()` to allow unknown keys through without validation:
```ts
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional(),
text: z.union([z.string(), z.array(z.string())]).optional()
})
.passthrough()
```
This means unknown keys are typed as `unknown` in TypeScript, requiring runtime validation when iterating over all output entries (e.g., to build a unified media list).
### Why not `.catchall(z.array(zResultItem))`?
`.catchall()` correctly handles this at the Zod runtime level — explicit keys override the catchall, so `animated: [true]` parses fine even when the catchall expects `ResultItem[]`.
However, TypeScript's type inference creates an index signature `[k: string]: ResultItem[]` that **conflicts** with the explicit fields `animated: boolean[]` and `text: string | string[]`. These types don't extend `ResultItem[]`, so TypeScript errors on any assignment.
This is a TypeScript limitation, not a Zod or schema design issue. TypeScript cannot express "index signature applies only to keys not explicitly defined."
### Why not remove `animated` and `text` from the schema?
- `animated` is consumed by `isAnimatedOutput()` in `litegraphUtil.ts` and by `litegraphService.ts` to determine whether to render images as static or animated. Removing it would break typing for the graph editor path.
- `text` is part of the `zExecutedWsMessage` validation pipeline. Removing it from `zOutputs` would cause `.catchall()` to reject `{ text: "hello" }` as invalid (it's not `ResultItem[]`).
- Both are structurally different from media outputs — they are metadata, not file references. Mixing them in the same object is a backend API constraint we cannot change here.
## Decision
1. **Keep `.passthrough()`** on `zOutputs`. It correctly reflects the extensible nature of the backend API.
2. **Use `resultItemType` (the Zod enum) for `type` field validation** in the shared `isResultItem` guard. We cannot use `zResultItem.safeParse()` directly because the Zod schema marks `filename` and `subfolder` as `.optional()` (matching the wire format), but a `ResultItemImpl` needs both fields to construct a valid preview URL. The shared guard requires `filename` and `subfolder` as strings while delegating `type` validation to the Zod enum.
3. **Accept the `unknown[]` cast** when iterating passthrough entries. The cast is honest — passthrough values genuinely are `unknown`, and runtime validation narrows them correctly.
4. **Centralize the `NodeExecutionOutput → ResultItemImpl[]` conversion** into a shared utility (`parseNodeOutput` / `parseTaskOutput` in `src/stores/resultItemParsing.ts`) to eliminate duplicated, inconsistent validation across `flattenNodeOutput.ts`, `jobOutputCache.ts`, and `queueStore.ts`.
## Consequences
### Positive
- Single source of truth for `ResultItem` validation (shared `isResultItem` guard using Zod's `resultItemType` enum)
- Consistent validation strictness across all code paths
- Clear documentation of why `.passthrough()` is intentional, preventing future "fix" attempts
- The `unknown[]` cast is contained to one location
### Negative
- Manual `isResultItem` guard is stricter than `zResultItem` Zod schema (requires `filename` and `subfolder`); if the Zod schema changes, the guard must be updated manually
- The `unknown[]` cast remains necessary — cannot be eliminated without a TypeScript language change or backend API restructuring
## Notes
The backend API's extensible output format is a deliberate design choice for ComfyUI's plugin architecture. Custom nodes define their own output types, and the frontend must handle arbitrary keys gracefully. Any future attempt to make the schema stricter must account for this extensibility requirement.
If TypeScript adds support for "rest index signatures" or "exclusive index signatures" in the future, `.catchall()` could replace `.passthrough()` and the `unknown[]` cast would be eliminated.

View File

@@ -8,13 +8,15 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| --------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
## Creating a New ADR

View File

@@ -201,7 +201,7 @@ function nodeToNodeData(node: LGraphNode) {
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-1 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
)
"

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
@@ -7,6 +8,15 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template: '<div class="job-details-popover-stub" />'
})
vi.mock('vue-i18n', () => {
return {
createI18n: () => ({
@@ -46,6 +56,7 @@ const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
workflow_id: 'workflow-1',
priority: 0
}
const flatOutputs = preview ? [preview] : []
@@ -71,11 +82,21 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
]
return mount(JobAssetsList, {
props: { displayedJobGroups }
props: { displayedJobGroups },
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
}
describe('JobAssetsList', () => {
afterEach(() => {
vi.useRealTimers()
})
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])
@@ -143,4 +164,143 @@ describe('JobAssetsList', () => {
expect(wrapper.emitted('viewItem')).toBeUndefined()
})
it('emits viewItem from the View button for completed jobs without preview output', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef()
})
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
await jobRow.trigger('mouseenter')
const viewButton = wrapper
.findAll('button')
.find((button) => button.text() === 'menuLabels.View')
expect(viewButton).toBeDefined()
await viewButton!.trigger('click')
await nextTick()
expect(wrapper.emitted('viewItem')).toEqual([[job]])
})
it('shows and hides the job details popover with hover delays', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(199)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: job.id,
workflowId: 'workflow-1'
})
await jobRow.trigger('mouseleave')
await vi.advanceTimersByTimeAsync(149)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
})
it('keeps the job details popover open while hovering the popover', async () => {
vi.useFakeTimers()
const job = buildJob()
const wrapper = mountJobAssetsList([job])
const jobRow = wrapper.find(`[data-job-id="${job.id}"]`)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
await jobRow.trigger('mouseleave')
await vi.advanceTimersByTimeAsync(100)
await nextTick()
const popover = wrapper.find('.job-details-popover')
expect(popover.exists()).toBe(true)
await popover.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
await popover.trigger('mouseleave')
await vi.advanceTimersByTimeAsync(149)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(true)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
})
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const wrapper = mountJobAssetsList([firstJob, secondJob])
const firstRow = wrapper.find('[data-job-id="job-1"]')
const secondRow = wrapper.find('[data-job-id="job-2"]')
await firstRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
'job-1'
)
await firstRow.trigger('mouseleave')
await secondRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(100)
await nextTick()
await secondRow.trigger('mouseleave')
await vi.advanceTimersByTimeAsync(150)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
})
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const wrapper = mountJobAssetsList([firstJob, secondJob])
const firstRow = wrapper.find('[data-job-id="job-1"]')
const secondRow = wrapper.find('[data-job-id="job-2"]')
await firstRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).props('jobId')).toBe(
'job-1'
)
await firstRow.trigger('mouseleave')
await secondRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(150)
await nextTick()
expect(wrapper.findComponent(JobDetailsPopoverStub).exists()).toBe(false)
await vi.advanceTimersByTimeAsync(50)
await nextTick()
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props('jobId')).toBe('job-2')
})
})

View File

@@ -8,78 +8,107 @@
<div class="text-xs leading-none text-text-secondary">
{{ group.label }}
</div>
<AssetsListItem
<div
v-for="job in group.items"
:key="job.id"
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="hoveredJobId = job.id"
:data-job-id="job.id"
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="$emit('cancelItem', job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="$emit('deleteItem', job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="$emit('viewItem', job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
right: `${popoverPosition.right}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover
:job-id="activeDetails.jobId"
:workflow-id="activeDetails.workflowId"
/>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
defineProps<{ displayedJobGroups: JobGroup[] }>()
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
@@ -90,11 +119,104 @@ const emit = defineEmits<{
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const activeDetails = ref<{ jobId: string; workflowId?: string } | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; right: number } | null>(null)
const hideTimer = ref<number | null>(null)
const hideTimerJobId = ref<string | null>(null)
const showTimer = ref<number | null>(null)
const clearHideTimer = () => {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
hideTimerJobId.value = null
}
const clearShowTimer = () => {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
const updatePopoverPosition = () => {
const rowElement = activeRowElement.value
if (!rowElement) return
const rect = rowElement.getBoundingClientRect()
const gap = 8
popoverPosition.value = {
top: rect.top,
right: window.innerWidth - rect.left + gap
}
}
const resetActiveDetails = () => {
clearHideTimer()
clearShowTimer()
activeDetails.value = null
activeRowElement.value = null
popoverPosition.value = null
}
const scheduleDetailsShow = (job: JobListItem, rowElement: HTMLElement) => {
clearShowTimer()
showTimer.value = window.setTimeout(() => {
activeRowElement.value = rowElement
activeDetails.value = {
jobId: job.id,
workflowId: job.taskRef?.workflowId
}
showTimer.value = null
void nextTick(updatePopoverPosition)
}, 200)
}
const scheduleDetailsHide = (jobId?: string) => {
if (!jobId) return
clearShowTimer()
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
return
}
clearHideTimer()
hideTimerJobId.value = jobId
hideTimer.value = window.setTimeout(() => {
if (activeDetails.value?.jobId === jobId) {
activeDetails.value = null
activeRowElement.value = null
popoverPosition.value = null
}
hideTimer.value = null
hideTimerJobId.value = null
}, 150)
}
const onJobLeave = (jobId: string) => {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId)
}
const onJobEnter = (job: JobListItem, event: MouseEvent) => {
hoveredJobId.value = job.id
const rowElement = event.currentTarget
if (!(rowElement instanceof HTMLElement)) return
activeRowElement.value = rowElement
if (activeDetails.value?.jobId === job.id) {
clearHideTimer()
clearShowTimer()
void nextTick(updatePopoverPosition)
return
}
scheduleDetailsShow(job, rowElement)
}
const isCancelable = (job: JobListItem) =>
@@ -121,10 +243,35 @@ const isPreviewableCompletedJob = (job: JobListItem) =>
const emitViewItem = (job: JobListItem) => {
if (isPreviewableCompletedJob(job)) {
resetActiveDetails()
emit('viewItem', job)
}
}
const emitCompletedViewItem = (job: JobListItem) => {
resetActiveDetails()
emit('viewItem', job)
}
const emitCancelItem = (job: JobListItem) => {
resetActiveDetails()
emit('cancelItem', job)
}
const emitDeleteItem = (job: JobListItem) => {
resetActiveDetails()
emit('deleteItem', job)
}
const onPopoverEnter = () => {
clearHideTimer()
clearShowTimer()
}
const onPopoverLeave = () => {
scheduleDetailsHide(activeDetails.value?.jobId)
}
const getJobIconClass = (job: JobListItem): string | undefined => {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
@@ -132,4 +279,22 @@ const getJobIconClass = (job: JobListItem): string | undefined => {
}
return undefined
}
watch(
() => displayedJobGroups,
(groups) => {
const activeJobId = activeDetails.value?.jobId
if (!activeJobId) return
const hasActiveJob = groups.some((group) =>
group.items.some((item) => item.id === activeJobId)
)
if (!hasActiveJob) {
resetActiveDetails()
}
}
)
onBeforeUnmount(resetActiveDetails)
</script>

View File

@@ -1,4 +1,4 @@
import DOMPurify from 'dompurify'
import dompurify from 'dompurify'
import type {
ContextMenuDivElement,
@@ -16,7 +16,7 @@ const ALLOWED_STYLE_PROPS = new Set([
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
dompurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
@@ -33,7 +33,7 @@ DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
return dompurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})

View File

@@ -206,7 +206,7 @@ describe.skip('Subgraph Serialization', () => {
})
describe.skip('Subgraph Known Issues', () => {
it.todo('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
it.skip('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
// This test documents that MAX_NESTED_SUBGRAPHS = 1000 is defined
// but not actually enforced anywhere in the code.
//

View File

@@ -48,7 +48,7 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
expect(firstLevel.isSubgraphNode()).toBe(true)
})
it.todo('should use WeakSet for cycle detection', () => {
it.skip('should use WeakSet for cycle detection', () => {
// TODO: This test is currently skipped because cycle detection has a bug
// The fix is to pass 'visited' directly instead of 'new Set(visited)' in SubgraphNode.ts:299
const subgraph = createTestSubgraph({ nodeCount: 1 })

View File

@@ -796,6 +796,7 @@
"filterVideo": "Video",
"filterAudio": "Audio",
"filter3D": "3D",
"filterImageCompare": "Image Compare",
"filterText": "Text",
"viewSettings": "View settings"
},
@@ -1876,7 +1877,9 @@
"imageCompare": {
"noImages": "No images to compare",
"batchLabelA": "A:",
"batchLabelB": "B:"
"batchLabelB": "B:",
"altBefore": "Before image",
"altAfter": "After image"
},
"batch": {
"index": "{current} / {total}"

View File

@@ -23,6 +23,27 @@ const {
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const pointerStart = ref<{ x: number; y: number } | null>(null)
function onPointerDown(e: PointerEvent) {
pointerStart.value = { x: e.clientX, y: e.clientY }
}
function onIndicatorClick(e: MouseEvent) {
if (e.detail !== 0) {
const start = pointerStart.value
if (start) {
const dx = e.clientX - start.x
const dy = e.clientY - start.y
if (dx * dx + dy * dy > 25) {
pointerStart.value = null
return
}
}
}
pointerStart.value = null
dropIndicator?.onClick?.(e)
}
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop: (_files, event) => {
@@ -70,16 +91,17 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
data-slot="drop-zone-indicator"
:class="
cn(
'm-3 block w-[calc(100%-1.5rem)] appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
'm-3 block h-42 min-h-32 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator?.onClick && 'cursor-pointer'
)
"
@click.prevent="dropIndicator?.onClick?.($event)"
@pointerdown="onPointerDown"
@click.prevent="onIndicatorClick"
>
<div
:class="
cn(
'flex min-h-23 w-full flex-col items-center justify-center gap-2 rounded-[7px] p-6 text-center text-sm/tight transition-colors',
'flex h-full max-w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-[7px] p-3 text-center text-sm/tight transition-colors',
isHovered &&
!dropIndicator?.imageUrl &&
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
@@ -88,7 +110,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
>
<img
v-if="dropIndicator?.imageUrl"
class="max-h-23 rounded-md object-contain"
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>

View File

@@ -0,0 +1,124 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ImageComparePreview from '@/renderer/extensions/linearMode/ImageComparePreview.vue'
import type { CompareImages } from '@/stores/queueStore'
import { ResultItemImpl } from '@/stores/queueStore'
function makeResultItem(filename: string): ResultItemImpl {
return new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
mediaType: 'images',
nodeId: '1'
})
}
function makeCompareImages(
beforeFiles: string[],
afterFiles: string[]
): CompareImages {
return {
before: beforeFiles.map(makeResultItem),
after: afterFiles.map(makeResultItem)
}
}
function mountComponent(compareImages: CompareImages) {
return mount(ImageComparePreview, {
global: {
mocks: {
$t: (key: string, params?: Record<string, unknown>) => {
if (key === 'batch.index' && params) {
return `${params.current} / ${params.total}`
}
return key
}
}
},
props: { compareImages }
})
}
describe('ImageComparePreview', () => {
it('renders both before and after images', () => {
const compareImages = makeCompareImages(['before.png'], ['after.png'])
const wrapper = mountComponent(compareImages)
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('alt')).toBe('imageCompare.altAfter')
expect(images[1].attributes('alt')).toBe('imageCompare.altBefore')
})
it('renders slider handle when both images present', () => {
const compareImages = makeCompareImages(['before.png'], ['after.png'])
const wrapper = mountComponent(compareImages)
const handles = wrapper.findAll('[role="presentation"]')
expect(handles.length).toBeGreaterThanOrEqual(1)
})
it('renders only before image when no after images', () => {
const compareImages = makeCompareImages(['before.png'], [])
const wrapper = mountComponent(compareImages)
const images = wrapper.findAll('img')
expect(images).toHaveLength(1)
expect(images[0].attributes('alt')).toBe('imageCompare.altBefore')
})
it('renders only after image when no before images', () => {
const compareImages = makeCompareImages([], ['after.png'])
const wrapper = mountComponent(compareImages)
const images = wrapper.findAll('img')
expect(images).toHaveLength(1)
expect(images[0].attributes('alt')).toBe('imageCompare.altAfter')
})
it('shows no-images message when both arrays are empty', () => {
const compareImages = makeCompareImages([], [])
const wrapper = mountComponent(compareImages)
expect(wrapper.findAll('img')).toHaveLength(0)
expect(wrapper.text()).toContain('imageCompare.noImages')
})
it('hides batch nav for single images', () => {
const compareImages = makeCompareImages(['before.png'], ['after.png'])
const wrapper = mountComponent(compareImages)
expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(false)
})
it('shows batch nav when multiple images on either side', () => {
const compareImages = makeCompareImages(['a1.png', 'a2.png'], ['b1.png'])
const wrapper = mountComponent(compareImages)
expect(wrapper.find('[data-testid="batch-nav"]').exists()).toBe(true)
})
it('navigates before images with batch controls', async () => {
const compareImages = makeCompareImages(
['a1.png', 'a2.png', 'a3.png'],
['b1.png']
)
const wrapper = mountComponent(compareImages)
const beforeBatch = wrapper.find('[data-testid="before-batch"]')
await beforeBatch.find('[data-testid="batch-next"]').trigger('click')
expect(beforeBatch.find('[data-testid="batch-counter"]').text()).toBe(
'2 / 3'
)
})
it('does not render slider handle when only one side has images', () => {
const compareImages = makeCompareImages(['before.png'], [])
const wrapper = mountComponent(compareImages)
expect(wrapper.find('[role="presentation"]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { useMouseInElement } from '@vueuse/core'
import { computed, ref, watchEffect } from 'vue'
import BatchNavigation from '@/renderer/extensions/vueNodes/widgets/components/BatchNavigation.vue'
import type { CompareImages } from '@/stores/queueStore'
const { compareImages } = defineProps<{
compareImages: CompareImages
}>()
const containerRef = ref<HTMLElement | null>(null)
const sliderPosition = ref(50)
const beforeIndex = ref(0)
const afterIndex = ref(0)
const imageAspect = ref('')
function onImageLoad(e: Event) {
const img = e.target as HTMLImageElement
if (img.naturalWidth && img.naturalHeight) {
imageAspect.value = `${img.naturalWidth} / ${img.naturalHeight}`
}
}
const { elementX, elementWidth } = useMouseInElement(containerRef)
watchEffect(() => {
const x = elementX.value
const width = elementWidth.value
if (width > 0) {
sliderPosition.value = Math.max(0, Math.min(100, (x / width) * 100))
}
})
const beforeCount = computed(() => compareImages.before.length)
const afterCount = computed(() => compareImages.after.length)
const showBatchNav = computed(
() => beforeCount.value > 1 || afterCount.value > 1
)
const beforeUrl = computed(() => {
const idx = Math.min(beforeIndex.value, beforeCount.value - 1)
return compareImages.before[Math.max(0, idx)]?.url ?? ''
})
const afterUrl = computed(() => {
const idx = Math.min(afterIndex.value, afterCount.value - 1)
return compareImages.after[Math.max(0, idx)]?.url ?? ''
})
const hasCompareImages = computed(() =>
Boolean(beforeUrl.value && afterUrl.value)
)
</script>
<template>
<div class="flex size-full flex-col overflow-hidden">
<div
v-if="showBatchNav"
class="flex shrink-0 justify-between px-2 py-1 text-xs"
data-testid="batch-nav"
>
<BatchNavigation
v-model="beforeIndex"
:count="beforeCount"
data-testid="before-batch"
>
<template #label>{{ $t('imageCompare.batchLabelA') }}</template>
</BatchNavigation>
<div v-if="beforeCount <= 1" />
<BatchNavigation
v-model="afterIndex"
:count="afterCount"
data-testid="after-batch"
>
<template #label>{{ $t('imageCompare.batchLabelB') }}</template>
</BatchNavigation>
</div>
<div
v-if="beforeUrl || afterUrl"
class="flex min-h-0 flex-1 items-center justify-center"
>
<div
ref="containerRef"
class="relative h-full max-w-full cursor-col-resize"
:style="imageAspect ? { aspectRatio: imageAspect } : undefined"
>
<img
:src="afterUrl || beforeUrl"
:alt="
afterUrl
? $t('imageCompare.altAfter')
: $t('imageCompare.altBefore')
"
draggable="false"
class="block size-full"
@load="onImageLoad"
/>
<img
v-if="hasCompareImages"
:src="beforeUrl"
:alt="$t('imageCompare.altBefore')"
draggable="false"
class="absolute inset-0 size-full object-cover"
:style="{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }"
/>
<div
v-if="hasCompareImages"
class="pointer-events-none absolute top-0 z-10 h-full w-0.5 -translate-x-1/2 bg-white/80"
:style="{ left: `${sliderPosition}%` }"
role="presentation"
/>
<div
v-if="hasCompareImages"
class="pointer-events-none absolute top-1/2 z-10 size-6 -translate-1/2 rounded-full border-2 border-white bg-white/30 shadow-lg backdrop-blur-sm"
:style="{ left: `${sliderPosition}%` }"
role="presentation"
/>
</div>
</div>
<div
v-else
class="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"
>
{{ $t('imageCompare.noImages') }}
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { defineAsyncComponent, useAttrs } from 'vue'
import ImageComparePreview from '@/renderer/extensions/linearMode/ImageComparePreview.vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
@@ -21,8 +22,13 @@ const { output } = defineProps<{
const attrs = useAttrs()
</script>
<template>
<ImageComparePreview
v-if="getMediaType(output) === 'image_compare' && output.compareImages"
:class="cn('flex-1', attrs.class as string)"
:compare-images="output.compareImages"
/>
<ImagePreview
v-if="getMediaType(output) === 'images'"
v-else-if="getMediaType(output) === 'images'"
:class="attrs.class as string"
:mobile
:src="output.url"

View File

@@ -71,6 +71,13 @@ const selectableItems = computed(() => {
itemId: item.id
})
}
for (const entry of store.activeWorkflowNonAssetOutputs) {
items.push({
id: `nonasset:${entry.id}`,
kind: 'nonAsset',
itemId: entry.id
})
}
for (const asset of outputs.media.value) {
const outs = allOutputs(asset)
for (let k = 0; k < outs.length; k++) {
@@ -137,6 +144,16 @@ function doEmit() {
}
return
}
if (sel.kind === 'nonAsset') {
const entry = store.activeWorkflowNonAssetOutputs.find(
(e) => e.id === sel.itemId
)
emit('updateSelection', {
output: entry?.output,
canShowPreview: true
})
return
}
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
const output = asset ? allOutputs(asset)[sel.key] : undefined
const isFirst = outputs.media.value[0]?.id === sel.assetId
@@ -206,6 +223,7 @@ useResizeObserver(outputsRef, () => {
watch(
[
() => store.activeWorkflowInProgressItems.length,
() => store.activeWorkflowNonAssetOutputs.length,
() => visibleHistory.value[0]?.id,
queueCount
],
@@ -350,11 +368,34 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
</div>
<div
v-if="hasActiveContent && visibleHistory.length > 0"
v-if="
hasActiveContent &&
(store.activeWorkflowNonAssetOutputs.length > 0 ||
visibleHistory.length > 0)
"
class="mx-4 h-12 shrink-0 border-l border-border-default"
/>
</div>
<div
v-for="entry in store.activeWorkflowNonAssetOutputs"
:key="entry.id"
:ref="selectedRef(`nonasset:${entry.id}`)"
v-bind="itemAttrs(`nonasset:${entry.id}`)"
:class="itemClass"
@click="store.select(`nonasset:${entry.id}`)"
>
<OutputHistoryItem :output="entry.output" />
</div>
<div
v-if="
store.activeWorkflowNonAssetOutputs.length > 0 &&
visibleHistory.length > 0
"
class="mx-4 h-12 shrink-0 border-l border-border-default"
/>
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
<div
v-if="aIdx > 0"

View File

@@ -0,0 +1,55 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
import type { CompareImages } from '@/stores/queueStore'
import { ResultItemImpl } from '@/stores/queueStore'
function makeResultItem(
filename: string,
mediaType: string,
compareImages?: CompareImages
): ResultItemImpl {
return new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
mediaType,
nodeId: '1',
compareImages
})
}
function mountComponent(output: ResultItemImpl) {
return mount(OutputHistoryItem, {
props: { output }
})
}
describe('OutputHistoryItem', () => {
it('renders split 50/50 thumbnail for image_compare items', () => {
const before = [makeResultItem('before.png', 'images')]
const after = [makeResultItem('after.png', 'images')]
const output = makeResultItem('before.png', 'image_compare', {
before,
after
})
const wrapper = mountComponent(output)
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('src')).toContain('before.png')
expect(images[1].attributes('src')).toContain('after.png')
})
it('renders image thumbnail for regular image items', () => {
const output = makeResultItem('photo.png', 'images')
const wrapper = mountComponent(output)
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toContain('photo.png')
})
})

View File

@@ -13,8 +13,27 @@ const { output } = defineProps<{
}>()
</script>
<template>
<div
v-if="getMediaType(output) === 'image_compare' && output.compareImages"
class="relative block size-10 overflow-hidden rounded-sm bg-secondary-background"
>
<img
v-if="output.compareImages.before[0]"
class="absolute inset-0 size-full object-cover"
loading="lazy"
:src="output.compareImages.before[0].url"
:style="{ clipPath: 'inset(0 50% 0 0)' }"
/>
<img
v-if="output.compareImages.after[0]"
class="absolute inset-0 size-full object-cover"
loading="lazy"
:src="output.compareImages.after[0].url"
:style="{ clipPath: 'inset(0 0 0 50%)' }"
/>
</div>
<img
v-if="getMediaType(output) === 'images'"
v-else-if="getMediaType(output) === 'images'"
class="block size-10 rounded-sm bg-secondary-background object-cover"
loading="lazy"
width="40"

View File

@@ -0,0 +1,127 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const initializeStandaloneViewer = vi.fn()
const cleanup = vi.fn()
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: () => ({
initializeStandaloneViewer,
cleanup,
handleMouseEnter: vi.fn(),
handleMouseLeave: vi.fn(),
handleResize: vi.fn(),
handleBackgroundImageUpdate: vi.fn(),
exportModel: vi.fn(),
handleSeek: vi.fn(),
isSplatModel: false,
isPlyModel: false,
hasSkeleton: false,
animations: [],
playing: false,
selectedSpeed: 1,
selectedAnimation: 0,
animationProgress: 0,
animationDuration: 0
})
}))
vi.mock('@/components/load3d/Load3DControls.vue', () => ({
default: { template: '<div />' }
}))
vi.mock('@/components/load3d/controls/AnimationControls.vue', () => ({
default: { template: '<div />' }
}))
describe('Preview3d', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
async function mountPreview3d(
modelUrl = 'http://localhost/view?filename=model.glb'
) {
const wrapper = mount(
(await import('@/renderer/extensions/linearMode/Preview3d.vue')).default,
{ props: { modelUrl } }
)
await nextTick()
await nextTick()
return wrapper
}
it('initializes the viewer on mount', async () => {
const wrapper = await mountPreview3d()
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'http://localhost/view?filename=model.glb'
)
wrapper.unmount()
})
it('cleans up the viewer on unmount', async () => {
const wrapper = await mountPreview3d()
cleanup.mockClear()
wrapper.unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('reinitializes correctly after unmount and remount', async () => {
const url = 'http://localhost/view?filename=model.glb'
const wrapper1 = await mountPreview3d(url)
expect(initializeStandaloneViewer).toHaveBeenCalledTimes(1)
cleanup.mockClear()
wrapper1.unmount()
expect(cleanup).toHaveBeenCalledOnce()
vi.clearAllMocks()
const wrapper2 = await mountPreview3d(url)
expect(initializeStandaloneViewer).toHaveBeenCalledTimes(1)
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
url
)
cleanup.mockClear()
wrapper2.unmount()
expect(cleanup).toHaveBeenCalledOnce()
})
it('reinitializes when modelUrl changes on existing instance', async () => {
const wrapper = await mountPreview3d(
'http://localhost/view?filename=model-a.glb'
)
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
vi.clearAllMocks()
await wrapper.setProps({
modelUrl: 'http://localhost/view?filename=model-b.glb'
})
await nextTick()
await nextTick()
expect(cleanup).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledOnce()
expect(initializeStandaloneViewer).toHaveBeenCalledWith(
expect.any(HTMLElement),
'http://localhost/view?filename=model-b.glb'
)
wrapper.unmount()
})
})

View File

@@ -13,11 +13,16 @@ const containerRef = useTemplateRef('containerRef')
const viewer = ref(useLoad3dViewer())
watch([containerRef, () => modelUrl], async () => {
if (!containerRef.value || !modelUrl) return
watch(
[containerRef, () => modelUrl],
async () => {
if (!containerRef.value || !modelUrl) return
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
})
viewer.value.cleanup()
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
},
{ flush: 'post' }
)
onUnmounted(() => {
viewer.value.cleanup()

View File

@@ -10,12 +10,7 @@ function makeOutput(
}
describe(flattenNodeOutput, () => {
it('returns empty array for output with no known media types', () => {
const result = flattenNodeOutput(['1', makeOutput({ text: 'hello' })])
expect(result).toEqual([])
})
it('flattens images into ResultItemImpl instances', () => {
it('delegates to parseNodeOutput and returns ResultItemImpl instances', () => {
const output = makeOutput({
images: [
{ filename: 'a.png', subfolder: '', type: 'output' },
@@ -33,53 +28,25 @@ describe(flattenNodeOutput, () => {
expect(result[1].subfolder).toBe('sub')
})
it('flattens audio outputs', () => {
const output = makeOutput({
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
})
const result = flattenNodeOutput([7, output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('audio')
expect(result[0].nodeId).toBe(7)
})
it('flattens multiple media types in a single output', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
})
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(2)
const types = result.map((r) => r.mediaType)
expect(types).toContain('images')
expect(types).toContain('video')
})
it('handles gifs and 3d output types', () => {
const output = makeOutput({
gifs: [
{ filename: 'anim.gif', subfolder: '', type: 'output' }
] as NodeExecutionOutput['gifs'],
'3d': [
{ filename: 'model.glb', subfolder: '', type: 'output' }
] as NodeExecutionOutput['3d']
})
const result = flattenNodeOutput(['5', output])
expect(result).toHaveLength(2)
const types = result.map((r) => r.mediaType)
expect(types).toContain('gifs')
expect(types).toContain('3d')
})
it('ignores empty arrays', () => {
const output = makeOutput({ images: [], audio: [] })
const result = flattenNodeOutput(['1', output])
it('returns empty array for text-only output', () => {
const result = flattenNodeOutput(['1', makeOutput({ text: 'hello' })])
expect(result).toEqual([])
})
it('combines a_images and b_images into a single image_compare item', () => {
const output = makeOutput({
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
} as unknown as Partial<NodeExecutionOutput>)
const result = flattenNodeOutput(['10', output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('image_compare')
expect(result[0].isImageCompare).toBe(true)
expect(result[0].compareImages!.before).toHaveLength(1)
expect(result[0].compareImages!.after).toHaveLength(1)
expect(result[0].compareImages!.before[0].filename).toBe('before.png')
expect(result[0].compareImages!.after[0].filename).toBe('after.png')
})
})

View File

@@ -1,20 +1,10 @@
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import { parseNodeOutput } from '@/stores/resultItemParsing'
import type { ResultItemImpl } from '@/stores/queueStore'
export function flattenNodeOutput([nodeId, nodeOutput]: [
string | number,
NodeExecutionOutput
]): ResultItemImpl[] {
const knownOutputs: Record<string, ResultItem[]> = {}
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
outputs.map(
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
)
)
return parseNodeOutput(nodeId, nodeOutput)
}

View File

@@ -20,3 +20,4 @@ export interface OutputSelection {
export type SelectionValue =
| { id: string; kind: 'inProgress'; itemId: string }
| { id: string; kind: 'history'; assetId: string; key: number }
| { id: string; kind: 'nonAsset'; itemId: string }

View File

@@ -4,7 +4,6 @@ import { ref } from 'vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
const activeJobIdRef = ref<string | null>(null)
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
@@ -64,22 +63,10 @@ vi.mock('@/scripts/api', () => ({
})
}))
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
flattenNodeOutput: ([nodeId, output]: [
string | number,
Record<string, unknown>
]) => {
if (!output.images) return []
return (output.images as Array<Record<string, string>>).map(
(img) =>
new ResultItemImpl({
...img,
nodeId: String(nodeId),
mediaType: 'images'
})
)
}
}))
vi.mock(
'@/renderer/extensions/linearMode/flattenNodeOutput',
async (importOriginal) => importOriginal()
)
function setJobWorkflowPath(jobId: string, path: string) {
const next = new Map(jobIdToWorkflowPathRef.value)
@@ -102,6 +89,23 @@ function makeExecutedDetail(
} as ExecutedWsMessage
}
function makeImageCompareDetail(
promptId: string,
aFilename = 'before.png',
bFilename = 'after.png',
nodeId = '2'
): ExecutedWsMessage {
return {
prompt_id: promptId,
node: nodeId,
display_node: nodeId,
output: {
a_images: [{ filename: aFilename, subfolder: '', type: 'temp' }],
b_images: [{ filename: bFilename, subfolder: '', type: 'temp' }]
}
} as ExecutedWsMessage
}
describe('linearOutputStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -1312,4 +1316,66 @@ describe('linearOutputStore', () => {
).toHaveLength(2)
})
})
describe('image compare outputs', () => {
it('stores standalone image_compare outputs in activeWorkflowNonAssetOutputs', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
store.onJobComplete('job-1')
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
expect(store.activeWorkflowNonAssetOutputs[0].output.isImageCompare).toBe(
true
)
expect(store.inProgressItems).toHaveLength(0)
})
it('separates image_compare to nonAssetOutputs and asset images to pendingResolve', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
store.onJobComplete('job-1')
// Asset images stay in pendingResolve
expect(store.pendingResolve.has('job-1')).toBe(true)
const remaining = store.inProgressItems.filter((i) => i.jobId === 'job-1')
expect(remaining).toHaveLength(1)
expect(remaining[0].output?.mediaType).toBe('images')
// Image compare moved to nonAssetOutputs
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
expect(store.activeWorkflowNonAssetOutputs[0].output.isImageCompare).toBe(
true
)
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-1')
})
it('scopes non-asset outputs to the active workflow', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/app-a.json')
setJobWorkflowPath('job-2', 'workflows/app-b.json')
activeWorkflowPathRef.value = 'workflows/app-a.json'
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeImageCompareDetail('job-1'))
store.onJobComplete('job-1')
store.onJobStart('job-2')
store.onNodeExecuted('job-2', makeImageCompareDetail('job-2'))
store.onJobComplete('job-2')
// Active workflow is app-a — only job-1 visible
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-1')
// Switch to app-b — only job-2 visible
activeWorkflowPathRef.value = 'workflows/app-b.json'
expect(store.activeWorkflowNonAssetOutputs).toHaveLength(1)
expect(store.activeWorkflowNonAssetOutputs[0].jobId).toBe('job-2')
})
})
})

View File

@@ -12,6 +12,8 @@ import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
const MAX_NON_ASSET_OUTPUTS = 64
export const useLinearOutputStore = defineStore('linearOutput', () => {
const { isAppMode } = useAppMode()
const appModeStore = useAppModeStore()
@@ -20,6 +22,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
const workflowStore = useWorkflowStore()
const inProgressItems = ref<InProgressItem[]>([])
const completedNonAssetOutputs = ref<
{ id: string; jobId: string; output: ResultItemImpl }[]
>([])
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
const selectedId = ref<string | null>(null)
const isFollowing = ref(true)
@@ -30,12 +35,19 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
const activeWorkflowInProgressItems = computed(() => {
const path = workflowStore.activeWorkflow?.path
if (!path) return []
const all = inProgressItems.value
return all.filter(
return inProgressItems.value.filter(
(i) => executionStore.jobIdToSessionWorkflowPath.get(i.jobId) === path
)
})
const activeWorkflowNonAssetOutputs = computed(() => {
const path = workflowStore.activeWorkflow?.path
if (!path) return []
return completedNonAssetOutputs.value.filter(
(e) => executionStore.jobIdToSessionWorkflowPath.get(e.jobId) === path
)
})
let nextSeq = 0
function makeItemId(jobId: string): string {
@@ -153,8 +165,15 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
return
}
// No skeleton — create image items directly (only for tracked job)
if (jobId !== trackedJobId.value) return
// No skeleton — create image items directly.
// handleExecuted already verified jobId === activeJobId, so start
// tracking if we haven't yet (covers nodes that fire before
// onJobStart, e.g. ImageCompare with no SaveImage in the workflow).
if (!trackedJobId.value) {
trackedJobId.value = jobId
} else if (jobId !== trackedJobId.value) {
return
}
const newItems: InProgressItem[] = newOutputs.map((o) => ({
id: makeItemId(jobId),
@@ -184,14 +203,31 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
trackedJobId.value = null
}
const hasImages = inProgressItems.value.some(
const jobImageItems = inProgressItems.value.filter(
(i) => i.jobId === jobId && i.state === 'image'
)
if (hasImages) {
// Remove non-image items (skeletons, latents), keep images for absorption
// Move non-asset outputs (e.g. image_compare) to their own collection
// since they won't appear in history.
const nonAssetItems = jobImageItems.filter((i) => i.output?.isImageCompare)
if (nonAssetItems.length > 0) {
completedNonAssetOutputs.value = [
...nonAssetItems.map((i) => ({
id: i.id,
jobId,
output: i.output!
})),
...completedNonAssetOutputs.value
].slice(0, MAX_NON_ASSET_OUTPUTS)
}
// Keep only asset images for history absorption, remove everything else.
const hasAssetOutputs = jobImageItems.some((i) => !i.output?.isImageCompare)
if (hasAssetOutputs) {
inProgressItems.value = inProgressItems.value.filter(
(i) => i.jobId !== jobId || i.state === 'image'
(i) =>
i.jobId !== jobId ||
(i.state === 'image' && !i.output?.isImageCompare)
)
pendingResolve.value = new Set([...pendingResolve.value, jobId])
} else {
@@ -357,6 +393,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
return {
activeWorkflowInProgressItems,
activeWorkflowNonAssetOutputs,
resolvedOutputsCache,
selectedId,
pendingResolve,

View File

@@ -8,6 +8,10 @@ export const mediaTypes: Record<string, StatItem> = {
content: t('sideToolbar.mediaAssets.filter3D'),
iconClass: 'icon-[lucide--box]'
},
image_compare: {
content: t('sideToolbar.mediaAssets.filterImageCompare'),
iconClass: 'icon-[lucide--columns-2]'
},
audio: {
content: t('sideToolbar.mediaAssets.filterAudio'),
iconClass: 'icon-[lucide--audio-lines]'
@@ -28,6 +32,7 @@ export const mediaTypes: Record<string, StatItem> = {
export function getMediaType(output?: ResultItemImpl) {
if (!output) return ''
if (output.isImageCompare) return 'image_compare'
if (output.isVideo) return 'video'
if (output.isImage) return 'images'
return output.mediaType

View File

@@ -23,6 +23,8 @@ const zResultItem = z.object({
display_name: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
// Uses .passthrough() because custom nodes can output arbitrary keys.
// See docs/adr/0007-node-execution-output-passthrough-schema.md
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),

View File

@@ -11,11 +11,11 @@ import QuickLRU from '@alloc/quick-lru'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resultItemType } from '@/schemas/apiSchema'
import type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
import type { TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import { parseTaskOutput } from '@/stores/resultItemParsing'
const MAX_TASK_CACHE_SIZE = 50
const MAX_JOB_DETAIL_CACHE_SIZE = 50
@@ -79,65 +79,7 @@ export async function getOutputsForTask(
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
const resultItems = Object.entries(outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs)
.filter(([mediaType, _]) => mediaType !== 'animated')
.flatMap(([mediaType, items]) => {
if (!Array.isArray(items)) {
return []
}
return items.filter(isResultItemLike).map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
})
)
return ResultItemImpl.filterPreviewable(resultItems)
}
function isResultItemLike(item: unknown): item is ResultItem {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return false
}
const candidate = item as Record<string, unknown>
if (
candidate.filename !== undefined &&
typeof candidate.filename !== 'string'
) {
return false
}
if (
candidate.subfolder !== undefined &&
typeof candidate.subfolder !== 'string'
) {
return false
}
if (
candidate.type !== undefined &&
!resultItemType.safeParse(candidate.type).success
) {
return false
}
if (
candidate.filename === undefined &&
candidate.subfolder === undefined &&
candidate.type === undefined
) {
return false
}
return true
return ResultItemImpl.filterPreviewable(parseTaskOutput(outputs))
}
export function getPreviewableOutputsFromJobDetail(

View File

@@ -98,6 +98,7 @@ vi.mock('@/stores/toastStore', () => ({
// Mock useDialogService
vi.mock('@/services/dialogService')
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
// Mock apiKeyAuthStore
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
@@ -185,7 +186,6 @@ describe('useFirebaseAuthStore', () => {
describe('token refresh events', () => {
beforeEach(async () => {
vi.resetModules()
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
(_auth, callback) => {

View File

@@ -66,7 +66,7 @@ vi.mock('@/scripts/api', () => ({
}))
describe('TaskItemImpl', () => {
it('should remove animated property from outputs during construction', () => {
it('should exclude animated from flatOutputs', () => {
const job = createHistoryJob(0, 'job-id')
const taskItem = new TaskItemImpl(job, {
'node-1': {
@@ -75,11 +75,9 @@ describe('TaskItemImpl', () => {
}
})
// Check that animated property was removed
expect('animated' in taskItem.outputs['node-1']).toBe(false)
expect(taskItem.outputs['node-1'].images).toBeDefined()
expect(taskItem.outputs['node-1'].images?.[0]?.filename).toBe('test.png')
expect(taskItem.flatOutputs).toHaveLength(1)
expect(taskItem.flatOutputs[0].filename).toBe('test.png')
expect(taskItem.flatOutputs[0].mediaType).toBe('images')
})
it('should handle outputs without animated property', () => {
@@ -202,8 +200,7 @@ describe('TaskItemImpl', () => {
const task = new TaskItemImpl(job)
expect(task.flatOutputs).toHaveLength(1)
expect(task.flatOutputs[0].filename).toBe('')
expect(task.flatOutputs).toHaveLength(0)
expect(task.previewableOutputs).toHaveLength(0)
expect(task.previewOutput).toBeUndefined()
})

View File

@@ -1,4 +1,3 @@
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
@@ -16,6 +15,7 @@ import type {
} from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { parseTaskOutput } from '@/stores/resultItemParsing'
import type { ComfyApp } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { getJobDetail } from '@/services/jobOutputCache'
@@ -32,12 +32,18 @@ enum TaskItemDisplayStatus {
Cancelled = 'Cancelled'
}
export interface CompareImages {
before: readonly ResultItemImpl[]
after: readonly ResultItemImpl[]
}
interface ResultItemInit extends ResultItem {
nodeId: NodeId
mediaType: string
format?: string
frame_rate?: number
display_name?: string
compareImages?: CompareImages
}
export class ResultItemImpl {
@@ -55,6 +61,8 @@ export class ResultItemImpl {
format?: string
frame_rate?: number
compareImages?: CompareImages
constructor(obj: ResultItemInit) {
this.filename = obj.filename ?? ''
this.subfolder = obj.subfolder ?? ''
@@ -67,6 +75,7 @@ export class ResultItemImpl {
this.format = obj.format
this.frame_rate = obj.frame_rate
this.compareImages = obj.compareImages
}
get urlParams(): URLSearchParams {
@@ -217,8 +226,18 @@ export class ResultItemImpl {
return getMediaTypeFromFilename(this.filename) === '3D'
}
get isImageCompare(): boolean {
return this.mediaType === 'image_compare'
}
get supportsPreview(): boolean {
return this.isImage || this.isVideo || this.isAudio || this.is3D
return (
this.isImage ||
this.isVideo ||
this.isAudio ||
this.is3D ||
this.isImageCompare
)
}
static filterPreviewable(
@@ -256,10 +275,7 @@ export class TaskItemImpl {
}
}
: {})
// Remove animated outputs from the outputs object
this.outputs = _.mapValues(effectiveOutputs, (nodeOutputs) =>
_.omit(nodeOutputs, 'animated')
)
this.outputs = effectiveOutputs
this.flatOutputs = flatOutputs ?? this.calculateFlatOutputs()
}
@@ -267,18 +283,7 @@ export class TaskItemImpl {
if (!this.outputs) {
return []
}
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as ResultItem[]).map(
(item: ResultItem) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
)
)
return parseTaskOutput(this.outputs)
}
/** All outputs that support preview (images, videos, audio, 3D) */

View File

@@ -0,0 +1,266 @@
import { describe, expect, it } from 'vitest'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import { parseNodeOutput, parseTaskOutput } from '@/stores/resultItemParsing'
function makeOutput(
overrides: Partial<NodeExecutionOutput> = {}
): NodeExecutionOutput {
return { ...overrides }
}
describe(parseNodeOutput, () => {
it('returns empty array for output with no known media types', () => {
const result = parseNodeOutput('1', makeOutput({ text: 'hello' }))
expect(result).toEqual([])
})
it('flattens images into ResultItemImpl instances', () => {
const output = makeOutput({
images: [
{ filename: 'a.png', subfolder: '', type: 'output' },
{ filename: 'b.png', subfolder: 'sub', type: 'output' }
]
})
const result = parseNodeOutput('42', output)
expect(result).toHaveLength(2)
expect(result[0].filename).toBe('a.png')
expect(result[0].nodeId).toBe('42')
expect(result[0].mediaType).toBe('images')
expect(result[1].filename).toBe('b.png')
expect(result[1].subfolder).toBe('sub')
})
it('flattens audio outputs', () => {
const output = makeOutput({
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
})
const result = parseNodeOutput(7, output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('audio')
expect(result[0].nodeId).toBe(7)
})
it('flattens multiple media types in a single output', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
})
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(2)
const types = result.map((r) => r.mediaType)
expect(types).toContain('images')
expect(types).toContain('video')
})
it('handles gifs and 3d output types', () => {
const output = makeOutput({
gifs: [
{ filename: 'anim.gif', subfolder: '', type: 'output' }
] as NodeExecutionOutput['gifs'],
'3d': [
{ filename: 'model.glb', subfolder: '', type: 'output' }
] as NodeExecutionOutput['3d']
})
const result = parseNodeOutput('5', output)
expect(result).toHaveLength(2)
const types = result.map((r) => r.mediaType)
expect(types).toContain('gifs')
expect(types).toContain('3d')
})
it('ignores empty arrays', () => {
const output = makeOutput({ images: [], audio: [] })
const result = parseNodeOutput('1', output)
expect(result).toEqual([])
})
it('excludes animated key', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
animated: [true]
})
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('excludes text key', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
text: 'some text output'
})
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('excludes non-ResultItem array items', () => {
const output = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
custom_data: [{ randomKey: 123 }]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('accepts items with filename but no subfolder', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ filename: 'no-subfolder.png' }
]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(2)
expect(result[0].filename).toBe('valid.png')
expect(result[1].filename).toBe('no-subfolder.png')
expect(result[1].subfolder).toBe('')
})
it('excludes items missing filename', () => {
const output = {
images: [
{ filename: 'valid.png', subfolder: '', type: 'output' },
{ subfolder: '', type: 'output' }
]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
describe('image compare outputs', () => {
it('produces a single image_compare item from a_images and b_images', () => {
const output = {
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('10', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('image_compare')
expect(result[0].nodeId).toBe('10')
expect(result[0].filename).toBe('before.png')
expect(result[0].compareImages).toBeDefined()
expect(result[0].compareImages!.before).toHaveLength(1)
expect(result[0].compareImages!.after).toHaveLength(1)
expect(result[0].compareImages!.before[0].filename).toBe('before.png')
expect(result[0].compareImages!.after[0].filename).toBe('after.png')
})
it('handles multiple batch images in a_images and b_images', () => {
const output = {
a_images: [
{ filename: 'a1.png', subfolder: '', type: 'output' },
{ filename: 'a2.png', subfolder: '', type: 'output' }
],
b_images: [
{ filename: 'b1.png', subfolder: '', type: 'output' },
{ filename: 'b2.png', subfolder: '', type: 'output' },
{ filename: 'b3.png', subfolder: '', type: 'output' }
]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('5', output)
expect(result).toHaveLength(1)
expect(result[0].compareImages!.before).toHaveLength(2)
expect(result[0].compareImages!.after).toHaveLength(3)
})
it('handles only a_images present', () => {
const output = {
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('image_compare')
expect(result[0].compareImages!.before).toHaveLength(1)
expect(result[0].compareImages!.after).toHaveLength(0)
})
it('handles only b_images present', () => {
const output = {
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('image_compare')
expect(result[0].compareImages!.before).toHaveLength(0)
expect(result[0].compareImages!.after).toHaveLength(1)
expect(result[0].filename).toBe('after.png')
})
it('includes other output keys alongside image compare', () => {
const output = {
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }],
images: [{ filename: 'extra.png', subfolder: '', type: 'output' }]
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(2)
expect(result[0].mediaType).toBe('image_compare')
expect(result[1].mediaType).toBe('images')
expect(result[1].filename).toBe('extra.png')
})
it('skips image compare when both a_images and b_images are empty', () => {
const output = {
a_images: [],
b_images: []
} as unknown as NodeExecutionOutput
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(0)
})
})
})
describe(parseTaskOutput, () => {
it('flattens across multiple nodes', () => {
const taskOutput: Record<string, NodeExecutionOutput> = {
'1': makeOutput({
images: [{ filename: 'a.png', subfolder: '', type: 'output' }]
}),
'2': makeOutput({
audio: [{ filename: 'b.wav', subfolder: '', type: 'output' }]
})
}
const result = parseTaskOutput(taskOutput)
expect(result).toHaveLength(2)
expect(result[0].nodeId).toBe('1')
expect(result[0].filename).toBe('a.png')
expect(result[1].nodeId).toBe('2')
expect(result[1].filename).toBe('b.wav')
})
})

View File

@@ -0,0 +1,92 @@
import type {
NodeExecutionOutput,
ResultItem,
ResultItemType
} from '@/schemas/apiSchema'
import { resultItemType } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
const METADATA_KEYS = new Set(['animated', 'text'])
const EXCLUDED_KEYS = new Set([...METADATA_KEYS, 'a_images', 'b_images'])
/**
* Validates that an unknown value is a well-formed ResultItem.
*
* Requires `filename` (string) since ResultItemImpl needs it for a valid URL.
* `subfolder` is optional here — ResultItemImpl constructor falls back to ''.
*/
function isResultItem(item: unknown): item is ResultItem {
if (!item || typeof item !== 'object' || Array.isArray(item)) return false
const candidate = item as Record<string, unknown>
if (typeof candidate.filename !== 'string') return false
if (
candidate.type !== undefined &&
!resultItemType.safeParse(candidate.type).success
) {
return false
}
return true
}
function toResultItems(
items: unknown[],
mediaType: string,
nodeId: string | number
): ResultItemImpl[] {
return items
.filter(isResultItem)
.map((item) => new ResultItemImpl({ ...item, mediaType, nodeId }))
}
function parseImageCompare(
nodeOutput: NodeExecutionOutput,
nodeId: string | number
): ResultItemImpl | null {
const aImages = nodeOutput.a_images
const bImages = nodeOutput.b_images
if (!Array.isArray(aImages) && !Array.isArray(bImages)) return null
const before = Array.isArray(aImages)
? toResultItems(aImages, 'images', nodeId)
: []
const after = Array.isArray(bImages)
? toResultItems(bImages, 'images', nodeId)
: []
if (before.length === 0 && after.length === 0) return null
const primary = before[0] ?? after[0]
return new ResultItemImpl({
filename: primary.filename,
subfolder: primary.subfolder,
type: primary.type as ResultItemType,
mediaType: 'image_compare',
nodeId,
compareImages: { before, after }
})
}
export function parseNodeOutput(
nodeId: string | number,
nodeOutput: NodeExecutionOutput
): ResultItemImpl[] {
const regularItems = Object.entries(nodeOutput)
.filter(([key, value]) => !EXCLUDED_KEYS.has(key) && Array.isArray(value))
.flatMap(([mediaType, items]) =>
toResultItems(items as unknown[], mediaType, nodeId)
)
const compareItem = parseImageCompare(nodeOutput, nodeId)
return compareItem ? [compareItem, ...regularItems] : regularItems
}
export function parseTaskOutput(
taskOutput: Record<string, NodeExecutionOutput>
): ResultItemImpl[] {
return Object.entries(taskOutput).flatMap(([nodeId, nodeOutput]) =>
parseNodeOutput(nodeId, nodeOutput)
)
}