Files
ComfyUI_frontend/src/platform/assets/utils/outputAssetUtil.test.ts
Dante 740df0470e feat: use cloud backend thumbnail resize for image previews (#9298)
## Summary

- In cloud mode, large generated images (4K, 8K+) cause browser freezing
when loaded at full resolution for preview display
- The cloud backend (ingest service) now supports a `res` query
parameter on `/api/view` that returns server-side resized JPEG (quality
80, max 512px) instead of redirecting to the full-size GCS original
- This PR adds `&res=512` to all image preview URLs in cloud mode,
reducing browser decode overhead from tens of MB to tens of KB
- Downloads still use the original resolution (no `res` param)
- No impact on localhost/desktop builds (`isCloud` compile-time
constant)

### without `?res`

302 -> png downloads
<img width="808" height="564" alt="스크린샷 2026-02-28 오후 6 53 03"
src="https://github.com/user-attachments/assets/7c1c62dd-0bc4-468d-9c74-7b98e892e126"
/>
<img width="323" height="137" alt="스크린샷 2026-02-28 오후 6 52 52"
src="https://github.com/user-attachments/assets/926aa0c4-856c-4057-96a0-d8fbd846762b"
/>

200 -> jpeg

### with `?res`
<img width="811" height="407" alt="스크린샷 2026-02-28 오후 6 51 55"
src="https://github.com/user-attachments/assets/d58d46ae-6749-4888-8bad-75344c4d868b"
/>


### Changes

- **New utility**: `getCloudResParam(filename?)` returns `&res=512` in
cloud mode for image files, empty string otherwise
- **Core stores**: `imagePreviewStore` appends `res` to node output
URLs; `queueStore.ResultItemImpl` gets a `previewUrl` getter (separates
preview from download URLs)
- **Applied to**: asset browser thumbnails, widget dropdown previews,
linear mode indicators, image compare node, background image upload

### Intentionally excluded

- Downloads (`getAssetUrl`) — need original resolution
- Mask editor — needs pixel-accurate data
- Audio/video/3D files — `res` only applies to raster images
- Execution-in-progress previews — use WebSocket blob URLs, not
`/api/view`

## Test plan

- [x] Unit tests for `getCloudResParam()` (5 tests: cloud/non-cloud,
image/non-image, undefined filename)
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All 5332 unit tests pass
- [x] Manual verification on cloud.comfy.org: `res=512` returns 200 with
resized JPEG; without `res` returns 302 redirect to GCS PNG original

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:56:06 +00:00

158 lines
4.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import { resolveOutputAssetItems } from './outputAssetUtil'
const mocks = vi.hoisted(() => ({
getJobDetail: vi.fn(),
getPreviewableOutputsFromJobDetail: vi.fn()
}))
vi.mock('@/services/jobOutputCache', () => ({
getJobDetail: mocks.getJobDetail,
getPreviewableOutputsFromJobDetail: mocks.getPreviewableOutputsFromJobDetail
}))
type OutputOverrides = Partial<{
filename: string
subfolder: string
nodeId: string
url: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
const merged = {
filename: 'file.png',
subfolder: 'sub',
nodeId: '1',
url: 'https://example.com/file.png',
...overrides
}
return {
...merged,
previewUrl: merged.url
} as ResultItemImpl
}
describe('resolveOutputAssetItems', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('maps outputs and excludes a composite output key', async () => {
const outputA = createOutput({
filename: 'a.png',
nodeId: '1',
url: 'https://example.com/a.png'
})
const outputB = createOutput({
filename: 'b.png',
nodeId: '2',
url: 'https://example.com/b.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-1',
nodeId: '1',
subfolder: 'sub',
executionTimeInSeconds: 12.5,
outputCount: 2,
allOutputs: [outputA, outputB]
}
const results = await resolveOutputAssetItems(metadata, {
createdAt: '2025-01-01T00:00:00.000Z',
excludeOutputKey: '2-sub-b.png'
})
expect(mocks.getJobDetail).not.toHaveBeenCalled()
expect(results).toHaveLength(1)
expect(results[0]).toEqual(
expect.objectContaining({
id: 'job-1-1-sub-a.png',
name: 'a.png',
created_at: '2025-01-01T00:00:00.000Z',
tags: ['output'],
preview_url: 'https://example.com/a.png'
})
)
expect(results[0].user_metadata).toEqual(
expect.objectContaining({
jobId: 'job-1',
nodeId: '1',
subfolder: 'sub',
executionTimeInSeconds: 12.5
})
)
})
it('loads full outputs when metadata indicates more outputs', async () => {
const previewOutput = createOutput({
filename: 'preview.png',
nodeId: '1',
url: 'https://example.com/preview.png'
})
const fullOutput = createOutput({
filename: 'full.png',
nodeId: '2',
url: 'https://example.com/full.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-2',
nodeId: '1',
subfolder: 'sub',
outputCount: 3,
allOutputs: [previewOutput]
}
const jobDetail = { id: 'job-1' }
mocks.getJobDetail.mockResolvedValue(jobDetail)
mocks.getPreviewableOutputsFromJobDetail.mockReturnValue([
fullOutput,
previewOutput
])
const results = await resolveOutputAssetItems(metadata)
expect(mocks.getJobDetail).toHaveBeenCalledWith('job-2')
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
jobDetail
)
expect(results.map((asset) => asset.name)).toEqual([
'full.png',
'preview.png'
])
})
it('keeps root outputs with empty subfolders', async () => {
const output = createOutput({
filename: 'root.png',
nodeId: '1',
subfolder: '',
url: 'https://example.com/root.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-root',
nodeId: '1',
subfolder: '',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(mocks.getJobDetail).not.toHaveBeenCalled()
expect(results).toHaveLength(1)
const [asset] = results
if (!asset) {
throw new Error('Expected a root output asset')
}
expect(asset.id).toBe('job-root-1--root.png')
if (!asset.user_metadata) {
throw new Error('Expected output metadata')
}
expect(asset.user_metadata.subfolder).toBe('')
})
})