mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
## Summary
This PR fixes missing-media false positives for annotated media widget
values such as:
```txt
photo.png [output]
clip.mp4 [input]
147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]
clip.mp4[input] // Cloud compact form
```
The change is intentionally scoped to the missing-media detection
pipeline for:
- `LoadImage`
- `LoadImageMask`
- `LoadVideo`
- `LoadAudio`
It preserves the raw widget value on `MissingMediaCandidate.name` for UI
display, grouping, replacement, and user-facing missing-media rows.
Normalized values are used only as comparison keys during verification.
## Diff Size
`main...HEAD` line diff is currently:
- Production/runtime code: `+478 / -37` (`515` changed lines)
- Unit test code: `+960 / -47` (`1,007` changed lines)
- Total: `+1,438 / -84` (`1,522` changed lines)
The PR looks large mostly because it locks both Cloud and OSS/Core
runtime paths with unit coverage; the production/runtime change is about
one third of the total diff.
## What Changed
- Added missing-media-scoped annotation helpers for detection-only path
normalization.
- Core/OSS recognizes spaced suffixes like `file.png [output]`.
- Cloud also recognizes compact suffixes like `file.png[output]`.
- User-selectable trailing `input` and `output` annotations are
normalized for matching.
- Unknown annotations and middle-of-filename annotations are left
unchanged.
- Added shared file-path helpers in `formatUtil`:
- `joinFilePath(subfolder, filename)`
- `getFilePathSeparatorVariants(filepath)`
- Updated media verification to compare candidates against both raw and
normalized match keys.
- Kept input candidates and generated output candidates in separate
identifier sets so an input asset cannot accidentally satisfy an output
reference with the same name.
- Moved missing-media source loading into `missingMediaAssetResolver` so
`missingMediaScan` remains focused on scan/verification orchestration.
- Updated Cloud generated-media verification to use the Cloud assets API
instead of job history:
- Cloud input candidates use input/public assets.
- Cloud output candidates use `output` tagged assets.
- Kept OSS/Core generated-media verification history-based, matching the
current generated-picker/widget availability model.
## Runtime Verification Paths
### Cloud
Cloud stores generated outputs as asset records. For an annotated output
value, this PR verifies against the `output` asset tag rather than job
history.
```txt
Widget value
"147257...d6e.png [output]"
|
v
Detection keys
"147257...d6e.png [output]"
"147257...d6e.png"
|
v
Cloud asset sources
input candidates -> /api/assets?include_tags=input&include_public=true
output candidates -> /api/assets?include_tags=output&include_public=true
|
v
Match against
asset.name
asset.asset_hash
subfolder/asset.name
subfolder/asset.asset_hash
slash and backslash separator variants
```
Example:
```ts
candidate.name = 'abc123.png [output]'
asset.name = 'ComfyUI_00001_.png'
asset.asset_hash = 'abc123.png'
asset.tags = ['output']
// Result: not missing
```
### OSS / Core
Core widget options for the normal loader nodes are input-folder based.
Annotated output values are resolved by Core through
`folder_paths.get_annotated_filepath()`, but the current generated
picker path is history-backed. This PR keeps OSS generated verification
aligned with that widget availability model instead of treating the full
output folder as the source of truth.
```txt
Widget value
"subfolder/photo.png [output]"
|
v
Detection keys
"subfolder/photo.png [output]"
"subfolder/photo.png"
|
v
OSS generated source
fetchHistoryPage(...)
|
v
History preview_output
filename: "photo.png"
subfolder: "subfolder"
|
v
Generated match keys
"subfolder/photo.png"
"subfolder\\photo.png"
```
This means OSS/Core verification is about whether the generated media is
currently available through the same generated/history-backed path the
widget uses, not a full disk-level executability check across the entire
output directory.
## Why Not Consolidate All Annotated Path Parsers
There are existing annotated-path parsers in image widget, Load3D, and
path creation code. This PR does not replace them.
The helper added here is detection-only: it strips annotations to build
comparison keys for missing-media verification. Parser consolidation
across widget implementations is intentionally left out of scope to keep
this fix narrow.
## Known Follow-Ups / Out Of Scope
- FE-620 tracks the separate video drag-and-drop upload race between
upload completion and missing-media detection.
- Published/shared workflow assets are still not fully represented by
`/api/assets?include_public=true`; that remains a backend/API contract
issue.
- A future backend/API contract that answers “is this workflow media
executable?” would be preferable to stitching together runtime-specific
FE sources.
- OSS/Core full output-folder scanning via `/internal/files/output` was
considered, but that endpoint is internal, shallow (`os.scandir`), and
not the same source currently used by the generated picker flow.
## Validation
- `pnpm test:unit -- missingMediaAssetResolver missingMediaScan
mediaPathDetectionUtil formatUtil`
- touched files `oxfmt`
- touched files `oxlint --fix`
- touched files `eslint --cache --fix --no-warn-ignored`
- `pnpm typecheck`
- pre-commit `pnpm knip --cache`
- pre-push `pnpm knip --cache`
`knip` passes with the existing tag hint:
```txt
Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer → @knipIgnoreUnusedButUsedByCustomNodes
```
## Screenshots
Before
https://github.com/user-attachments/assets/50eab565-3160-4a57-a758-87ec2c09071e
After
https://github.com/user-attachments/assets/08adcbbd-c3fc-43f9-b86c-327e4eb5abd8
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12069-fix-handle-annotated-output-media-paths-in-missing-media-scan-3596d73d365081f4afa3d4dd45cad3da)
by [Unito](https://www.unito.io)
430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
appendWorkflowJsonExt,
|
|
ensureWorkflowSuffix,
|
|
getFilePathSeparatorVariants,
|
|
getFilenameDetails,
|
|
getMediaTypeFromFilename,
|
|
getPathDetails,
|
|
highlightQuery,
|
|
isCivitaiModelUrl,
|
|
isPreviewableMediaType,
|
|
joinFilePath,
|
|
truncateFilename
|
|
} from './formatUtil'
|
|
|
|
describe('formatUtil', () => {
|
|
describe('truncateFilename', () => {
|
|
it('should not truncate short filenames', () => {
|
|
expect(truncateFilename('test.png')).toBe('test.png')
|
|
expect(truncateFilename('short.jpg', 10)).toBe('short.jpg')
|
|
})
|
|
|
|
it('should truncate long filenames while preserving extension', () => {
|
|
const longName = 'this-is-a-very-long-filename-that-needs-truncation.png'
|
|
const truncated = truncateFilename(longName, 20)
|
|
expect(truncated).toContain('...')
|
|
expect(truncated.endsWith('.png')).toBe(true)
|
|
expect(truncated.length).toBeLessThanOrEqual(25) // 20 + '...' + extension
|
|
})
|
|
|
|
it('should handle filenames without extensions', () => {
|
|
const longName = 'this-is-a-very-long-filename-without-extension'
|
|
const truncated = truncateFilename(longName, 20)
|
|
expect(truncated).toContain('...')
|
|
expect(truncated.length).toBeLessThanOrEqual(23) // 20 + '...'
|
|
})
|
|
|
|
it('should handle empty strings', () => {
|
|
expect(truncateFilename('')).toBe('')
|
|
expect(truncateFilename('', 10)).toBe('')
|
|
})
|
|
|
|
it('should preserve the start and end of the filename', () => {
|
|
const longName = 'ComfyUI_00001_timestamp_2024_01_01.png'
|
|
const truncated = truncateFilename(longName, 20)
|
|
expect(truncated).toMatch(/^ComfyUI.*01\.png$/)
|
|
expect(truncated).toContain('...')
|
|
})
|
|
|
|
it('should handle files with multiple dots', () => {
|
|
const filename = 'my.file.with.multiple.dots.txt'
|
|
const truncated = truncateFilename(filename, 15)
|
|
expect(truncated.endsWith('.txt')).toBe(true)
|
|
expect(truncated).toContain('...')
|
|
})
|
|
})
|
|
|
|
describe('getMediaTypeFromFilename', () => {
|
|
describe('image files', () => {
|
|
const imageTestCases = [
|
|
{ filename: 'test.png', expected: 'image' },
|
|
{ filename: 'photo.jpg', expected: 'image' },
|
|
{ filename: 'image.jpeg', expected: 'image' },
|
|
{ filename: 'animation.gif', expected: 'image' },
|
|
{ filename: 'web.webp', expected: 'image' },
|
|
{ filename: 'bitmap.bmp', expected: 'image' },
|
|
{ filename: 'modern.avif', expected: 'image' },
|
|
{ filename: 'logo.svg', expected: 'image' }
|
|
]
|
|
|
|
it.for(imageTestCases)(
|
|
'should identify $filename as $expected',
|
|
({ filename, expected }) => {
|
|
expect(getMediaTypeFromFilename(filename)).toBe(expected)
|
|
}
|
|
)
|
|
|
|
it('should handle uppercase extensions', () => {
|
|
expect(getMediaTypeFromFilename('test.PNG')).toBe('image')
|
|
expect(getMediaTypeFromFilename('photo.JPG')).toBe('image')
|
|
})
|
|
})
|
|
|
|
describe('video files', () => {
|
|
it('should identify video extensions correctly', () => {
|
|
expect(getMediaTypeFromFilename('video.mp4')).toBe('video')
|
|
expect(getMediaTypeFromFilename('clip.webm')).toBe('video')
|
|
expect(getMediaTypeFromFilename('movie.mov')).toBe('video')
|
|
expect(getMediaTypeFromFilename('film.avi')).toBe('video')
|
|
})
|
|
})
|
|
|
|
describe('audio files', () => {
|
|
it('should identify audio extensions correctly', () => {
|
|
expect(getMediaTypeFromFilename('song.mp3')).toBe('audio')
|
|
expect(getMediaTypeFromFilename('sound.wav')).toBe('audio')
|
|
expect(getMediaTypeFromFilename('music.ogg')).toBe('audio')
|
|
expect(getMediaTypeFromFilename('audio.flac')).toBe('audio')
|
|
})
|
|
})
|
|
|
|
describe('3D files', () => {
|
|
it('should identify 3D file extensions correctly', () => {
|
|
expect(getMediaTypeFromFilename('model.obj')).toBe('3D')
|
|
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
|
|
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
|
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
|
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
|
|
})
|
|
})
|
|
|
|
describe('text files', () => {
|
|
it('should identify text file extensions correctly', () => {
|
|
expect(getMediaTypeFromFilename('notes.txt')).toBe('text')
|
|
expect(getMediaTypeFromFilename('readme.md')).toBe('text')
|
|
expect(getMediaTypeFromFilename('data.json')).toBe('text')
|
|
expect(getMediaTypeFromFilename('table.csv')).toBe('text')
|
|
expect(getMediaTypeFromFilename('config.yaml')).toBe('text')
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty strings', () => {
|
|
expect(getMediaTypeFromFilename('')).toBe('other')
|
|
})
|
|
|
|
it('should handle files without extensions', () => {
|
|
expect(getMediaTypeFromFilename('README')).toBe('other')
|
|
})
|
|
|
|
it('should handle unknown extensions', () => {
|
|
expect(getMediaTypeFromFilename('document.pdf')).toBe('other')
|
|
expect(getMediaTypeFromFilename('archive.bin')).toBe('other')
|
|
})
|
|
|
|
it('should handle files with multiple dots', () => {
|
|
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
|
|
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other')
|
|
})
|
|
|
|
it('should handle paths with directories', () => {
|
|
expect(getMediaTypeFromFilename('/path/to/image.png')).toBe('image')
|
|
expect(getMediaTypeFromFilename('C:\\Windows\\video.mp4')).toBe('video')
|
|
})
|
|
|
|
it('should handle null and undefined gracefully', () => {
|
|
expect(getMediaTypeFromFilename(null)).toBe('other')
|
|
expect(getMediaTypeFromFilename(undefined)).toBe('other')
|
|
})
|
|
|
|
it('should handle special characters in filenames', () => {
|
|
expect(getMediaTypeFromFilename('test@#$.png')).toBe('image')
|
|
expect(getMediaTypeFromFilename('video (1).mp4')).toBe('video')
|
|
expect(getMediaTypeFromFilename('[2024] audio.mp3')).toBe('audio')
|
|
})
|
|
|
|
it('should handle very long filenames', () => {
|
|
const longFilename = 'a'.repeat(1000) + '.png'
|
|
expect(getMediaTypeFromFilename(longFilename)).toBe('image')
|
|
})
|
|
|
|
it('should handle mixed case extensions', () => {
|
|
expect(getMediaTypeFromFilename('test.PnG')).toBe('image')
|
|
expect(getMediaTypeFromFilename('video.Mp4')).toBe('video')
|
|
expect(getMediaTypeFromFilename('audio.WaV')).toBe('audio')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('highlightQuery', () => {
|
|
it('should return text unchanged when query is empty', () => {
|
|
expect(highlightQuery('Hello World', '')).toBe('Hello World')
|
|
})
|
|
|
|
it('should wrap matching text in highlight span', () => {
|
|
const result = highlightQuery('Hello World', 'World')
|
|
expect(result).toBe('Hello <span class="highlight">World</span>')
|
|
})
|
|
|
|
it('should be case-insensitive', () => {
|
|
const result = highlightQuery('Hello World', 'hello')
|
|
expect(result).toBe('<span class="highlight">Hello</span> World')
|
|
})
|
|
|
|
it('should sanitize text by default', () => {
|
|
const result = highlightQuery('<script>alert("xss")</script>', 'alert')
|
|
expect(result).not.toContain('<script>')
|
|
})
|
|
|
|
it('should skip sanitization when sanitize is false', () => {
|
|
const result = highlightQuery('<b>bold</b>', 'bold', false)
|
|
expect(result).toContain('<b>')
|
|
})
|
|
|
|
it('should escape special regex characters in query', () => {
|
|
const result = highlightQuery('price is $10.00', '$10')
|
|
expect(result).toContain('<span class="highlight">$10</span>')
|
|
})
|
|
|
|
it('should highlight multiple occurrences', () => {
|
|
const result = highlightQuery('foo bar foo', 'foo')
|
|
expect(result).toBe(
|
|
'<span class="highlight">foo</span> bar <span class="highlight">foo</span>'
|
|
)
|
|
})
|
|
|
|
it('should highlight cross-word matches', () => {
|
|
const result = highlightQuery('convert image to mask', 'geto', false)
|
|
expect(result).toBe(
|
|
'convert ima<span class="highlight">ge to</span> mask'
|
|
)
|
|
})
|
|
|
|
it('should not match across line breaks', () => {
|
|
const result = highlightQuery('ge\nto', 'geto', false)
|
|
expect(result).toBe('ge\nto')
|
|
})
|
|
|
|
it('should not match across tabs', () => {
|
|
const result = highlightQuery('ge\tto', 'geto', false)
|
|
expect(result).toBe('ge\tto')
|
|
})
|
|
|
|
it('should not match across multiple spaces', () => {
|
|
const result = highlightQuery('ge to', 'geto', false)
|
|
expect(result).toBe('ge to')
|
|
})
|
|
})
|
|
|
|
describe('getFilenameDetails', () => {
|
|
it('splits simple filenames into name and suffix', () => {
|
|
expect(getFilenameDetails('file.txt')).toEqual({
|
|
filename: 'file',
|
|
suffix: 'txt'
|
|
})
|
|
})
|
|
|
|
it('handles filenames with multiple dots', () => {
|
|
expect(getFilenameDetails('my.file.name.png')).toEqual({
|
|
filename: 'my.file.name',
|
|
suffix: 'png'
|
|
})
|
|
})
|
|
|
|
it('handles filenames without extension', () => {
|
|
expect(getFilenameDetails('README')).toEqual({
|
|
filename: 'README',
|
|
suffix: null
|
|
})
|
|
})
|
|
|
|
it('recognises .app.json as a compound extension', () => {
|
|
expect(getFilenameDetails('workflow.app.json')).toEqual({
|
|
filename: 'workflow',
|
|
suffix: 'app.json'
|
|
})
|
|
})
|
|
|
|
it('recognises .app.json case-insensitively', () => {
|
|
expect(getFilenameDetails('Workflow.APP.JSON')).toEqual({
|
|
filename: 'Workflow',
|
|
suffix: 'app.json'
|
|
})
|
|
})
|
|
|
|
it('handles regular .json files normally', () => {
|
|
expect(getFilenameDetails('workflow.json')).toEqual({
|
|
filename: 'workflow',
|
|
suffix: 'json'
|
|
})
|
|
})
|
|
|
|
it('treats bare .app.json as a dotfile without basename', () => {
|
|
expect(getFilenameDetails('.app.json')).toEqual({
|
|
filename: '.app',
|
|
suffix: 'json'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('getPathDetails', () => {
|
|
it('splits a path with .app.json extension', () => {
|
|
const result = getPathDetails('workflows/test.app.json')
|
|
expect(result).toEqual({
|
|
directory: 'workflows',
|
|
fullFilename: 'test.app.json',
|
|
filename: 'test',
|
|
suffix: 'app.json'
|
|
})
|
|
})
|
|
|
|
it('splits a path with .json extension', () => {
|
|
const result = getPathDetails('workflows/test.json')
|
|
expect(result).toEqual({
|
|
directory: 'workflows',
|
|
fullFilename: 'test.json',
|
|
filename: 'test',
|
|
suffix: 'json'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('joinFilePath', () => {
|
|
it('joins subfolder and filename with normalized slash separators', () => {
|
|
expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
|
|
'nested/folder/child/file.png'
|
|
)
|
|
})
|
|
|
|
it('trims boundary separators without changing the filename body', () => {
|
|
expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
|
|
'nested/folder/file.png'
|
|
)
|
|
})
|
|
|
|
it('returns the normalized filename when no subfolder is provided', () => {
|
|
expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
|
|
})
|
|
|
|
it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
|
|
expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
|
|
expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
|
|
})
|
|
})
|
|
|
|
describe('getFilePathSeparatorVariants', () => {
|
|
it('returns slash and backslash variants for nested paths', () => {
|
|
expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
|
|
'nested/folder/file.png',
|
|
'nested\\folder\\file.png'
|
|
])
|
|
})
|
|
|
|
it('returns a single value when no separator is present', () => {
|
|
expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
|
|
})
|
|
})
|
|
|
|
describe('appendWorkflowJsonExt', () => {
|
|
it('appends .app.json when isApp is true', () => {
|
|
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
|
|
})
|
|
|
|
it('appends .json when isApp is false', () => {
|
|
expect(appendWorkflowJsonExt('test', false)).toBe('test.json')
|
|
})
|
|
|
|
it('replaces .json with .app.json when isApp is true', () => {
|
|
expect(appendWorkflowJsonExt('test.json', true)).toBe('test.app.json')
|
|
})
|
|
|
|
it('replaces .app.json with .json when isApp is false', () => {
|
|
expect(appendWorkflowJsonExt('test.app.json', false)).toBe('test.json')
|
|
})
|
|
|
|
it('leaves .app.json unchanged when isApp is true', () => {
|
|
expect(appendWorkflowJsonExt('test.app.json', true)).toBe('test.app.json')
|
|
})
|
|
|
|
it('leaves .json unchanged when isApp is false', () => {
|
|
expect(appendWorkflowJsonExt('test.json', false)).toBe('test.json')
|
|
})
|
|
|
|
it('handles case-insensitive extensions', () => {
|
|
expect(appendWorkflowJsonExt('test.JSON', true)).toBe('test.app.json')
|
|
expect(appendWorkflowJsonExt('test.APP.JSON', false)).toBe('test.json')
|
|
})
|
|
})
|
|
|
|
describe('ensureWorkflowSuffix', () => {
|
|
it('appends suffix when missing', () => {
|
|
expect(ensureWorkflowSuffix('file', 'json')).toBe('file.json')
|
|
})
|
|
|
|
it('does not double-append when suffix already present', () => {
|
|
expect(ensureWorkflowSuffix('file.json', 'json')).toBe('file.json')
|
|
})
|
|
|
|
it('appends compound suffix when missing', () => {
|
|
expect(ensureWorkflowSuffix('file', 'app.json')).toBe('file.app.json')
|
|
})
|
|
|
|
it('does not double-append compound suffix', () => {
|
|
expect(ensureWorkflowSuffix('file.app.json', 'app.json')).toBe(
|
|
'file.app.json'
|
|
)
|
|
})
|
|
|
|
it('replaces .json with .app.json when suffix is app.json', () => {
|
|
expect(ensureWorkflowSuffix('file.json', 'app.json')).toBe(
|
|
'file.app.json'
|
|
)
|
|
})
|
|
|
|
it('replaces .app.json with .json when suffix is json', () => {
|
|
expect(ensureWorkflowSuffix('file.app.json', 'json')).toBe('file.json')
|
|
})
|
|
|
|
it('handles case-insensitive extension detection', () => {
|
|
expect(ensureWorkflowSuffix('file.JSON', 'json')).toBe('file.json')
|
|
expect(ensureWorkflowSuffix('file.APP.JSON', 'app.json')).toBe(
|
|
'file.app.json'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('isPreviewableMediaType', () => {
|
|
it('returns true for image/video/audio/3D', () => {
|
|
expect(isPreviewableMediaType('image')).toBe(true)
|
|
expect(isPreviewableMediaType('video')).toBe(true)
|
|
expect(isPreviewableMediaType('audio')).toBe(true)
|
|
expect(isPreviewableMediaType('3D')).toBe(true)
|
|
})
|
|
|
|
it('returns false for text/other', () => {
|
|
expect(isPreviewableMediaType('text')).toBe(false)
|
|
expect(isPreviewableMediaType('other')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isCivitaiModelUrl', () => {
|
|
it('recognizes civitai.red model URLs', () => {
|
|
expect(
|
|
isCivitaiModelUrl('https://civitai.red/api/download/models/123456')
|
|
).toBe(true)
|
|
})
|
|
})
|
|
})
|