mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 13:10:24 +00:00
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>
This commit is contained in:
@@ -40,6 +40,7 @@ import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -88,6 +89,7 @@ const handleFileUpload = async (event: Event) => {
|
||||
type: 'input',
|
||||
subfolder: 'backgrounds'
|
||||
})
|
||||
appendCloudResParam(params, file.name)
|
||||
modelValue.value = `/api/view?${params.toString()}`
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
@@ -26,8 +27,11 @@ useExtensionService().registerExtension({
|
||||
const { a_images: aImages, b_images: bImages } = output
|
||||
const rand = app.getRandParam()
|
||||
|
||||
const toUrl = (params: Record<string, string>) =>
|
||||
api.apiURL(`/view?${new URLSearchParams(params)}${rand}`)
|
||||
const toUrl = (record: Record<string, string>) => {
|
||||
const params = new URLSearchParams(record)
|
||||
appendCloudResParam(params)
|
||||
return api.apiURL(`/view?${params}${rand}`)
|
||||
}
|
||||
|
||||
const beforeImages =
|
||||
aImages && aImages.length > 0 ? aImages.map(toUrl) : []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
@@ -43,7 +44,7 @@ export function mapTaskOutputToAssetItem(
|
||||
? new Date(taskItem.executionStartTimestamp).toISOString()
|
||||
: new Date().toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
preview_url: output.previewUrl,
|
||||
user_metadata: metadata
|
||||
}
|
||||
}
|
||||
@@ -60,14 +61,15 @@ export function mapInputFileToAssetItem(
|
||||
index: number,
|
||||
directory: 'input' | 'output' = 'input'
|
||||
): AssetItem {
|
||||
const params = new URLSearchParams({ filename, type: directory })
|
||||
appendCloudResParam(params, filename)
|
||||
|
||||
return {
|
||||
id: `${directory}-${index}-${filename}`,
|
||||
name: filename,
|
||||
size: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: [directory],
|
||||
preview_url: api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(filename)}&type=${directory}`
|
||||
)
|
||||
preview_url: api.apiURL(`/view?${params}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,16 @@ type OutputOverrides = Partial<{
|
||||
}>
|
||||
|
||||
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
|
||||
return {
|
||||
const merged = {
|
||||
filename: 'file.png',
|
||||
subfolder: 'sub',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/file.png',
|
||||
...overrides
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
previewUrl: merged.url
|
||||
} as ResultItemImpl
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ function mapOutputsToAssetItems({
|
||||
size: 0,
|
||||
created_at: createdAtValue,
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
preview_url: output.previewUrl,
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId: output.nodeId,
|
||||
|
||||
56
src/platform/distribution/cloudPreviewUtil.test.ts
Normal file
56
src/platform/distribution/cloudPreviewUtil.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { appendCloudResParam } from './cloudPreviewUtil'
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('./types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
function buildParams(filename?: string): URLSearchParams {
|
||||
const params = new URLSearchParams()
|
||||
appendCloudResParam(params, filename)
|
||||
return params
|
||||
}
|
||||
|
||||
describe('appendCloudResParam', () => {
|
||||
it('does not set res in non-cloud mode', () => {
|
||||
mockIsCloud.value = false
|
||||
expect(buildParams('test.png').has('res')).toBe(false)
|
||||
})
|
||||
|
||||
it.for(['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'tiff', 'tif'])(
|
||||
'sets res=512 for .%s in cloud mode',
|
||||
(ext) => {
|
||||
mockIsCloud.value = true
|
||||
const params = buildParams(`file.${ext}`)
|
||||
expect(params.get('res')).toBe('512')
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
'video.mp4',
|
||||
'video.webm',
|
||||
'audio.mp3',
|
||||
'audio.wav',
|
||||
'model.glb',
|
||||
'icon.svg'
|
||||
])('does not set res for %s in cloud mode', (name) => {
|
||||
mockIsCloud.value = true
|
||||
expect(buildParams(name).has('res')).toBe(false)
|
||||
})
|
||||
|
||||
it('sets res=512 when no filename provided in cloud mode', () => {
|
||||
mockIsCloud.value = true
|
||||
expect(buildParams().get('res')).toBe('512')
|
||||
expect(buildParams(undefined).get('res')).toBe('512')
|
||||
})
|
||||
|
||||
it('does not set res when no filename provided in non-cloud mode', () => {
|
||||
mockIsCloud.value = false
|
||||
expect(buildParams().has('res')).toBe(false)
|
||||
})
|
||||
})
|
||||
19
src/platform/distribution/cloudPreviewUtil.ts
Normal file
19
src/platform/distribution/cloudPreviewUtil.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
import { isCloud } from './types'
|
||||
|
||||
/**
|
||||
* Appends `res=512` to the given URLSearchParams when in cloud mode
|
||||
* and the file is an image (or filename is unknown).
|
||||
*
|
||||
* The cloud backend resizes images server-side to prevent
|
||||
* the frontend from loading very large originals for previews.
|
||||
*/
|
||||
export function appendCloudResParam(
|
||||
params: URLSearchParams,
|
||||
filename?: string
|
||||
): void {
|
||||
if (!isCloud) return
|
||||
if (filename && getMediaTypeFromFilename(filename) !== 'image') return
|
||||
params.set('res', '512')
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -88,13 +89,16 @@ function getDropIndicator(node: LGraphNode) {
|
||||
const filename = node.widgets?.[0]?.value
|
||||
const resultItem = { type: 'input', filename: `${filename}` }
|
||||
|
||||
const buildImageUrl = () => {
|
||||
if (!filename) return undefined
|
||||
const params = new URLSearchParams(resultItem)
|
||||
appendCloudResParam(params, String(filename))
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
}
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl: filename
|
||||
? api.apiURL(
|
||||
`/view?${new URLSearchParams(resultItem)}${app.getPreviewFormatParam()}`
|
||||
)
|
||||
: undefined,
|
||||
imageUrl: buildImageUrl(),
|
||||
label: t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, provide, ref, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import {
|
||||
@@ -460,7 +461,9 @@ function getMediaUrl(
|
||||
type: 'input' | 'output' = 'input'
|
||||
): string {
|
||||
if (!['image', 'video'].includes(props.assetKind ?? '')) return ''
|
||||
return `/api/view?filename=${encodeURIComponent(filename)}&type=${type}`
|
||||
const params = new URLSearchParams({ filename, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return `/api/view?${params}`
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ResultItem,
|
||||
ResultItemType
|
||||
} from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
@@ -118,10 +119,13 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
const isImage = isImageOutputs(node, outputs)
|
||||
const firstFilename = outputs.images[0]?.filename
|
||||
|
||||
return outputs.images.map((image) => {
|
||||
const imgUrlPart = new URLSearchParams(image)
|
||||
return api.apiURL(`/view?${imgUrlPart}${previewParam}${rand}`)
|
||||
const params = new URLSearchParams(image)
|
||||
if (isImage) appendCloudResParam(params, firstFilename)
|
||||
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
StatusWsMessageStatus,
|
||||
TaskOutput
|
||||
} from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
@@ -91,6 +92,13 @@ export class ResultItemImpl {
|
||||
return api.apiURL('/view?' + this.urlParams)
|
||||
}
|
||||
|
||||
get previewUrl(): string {
|
||||
if (!this.isImage) return this.url
|
||||
const params = new URLSearchParams(this.urlParams)
|
||||
appendCloudResParam(params, this.filename)
|
||||
return api.apiURL('/view?' + params)
|
||||
}
|
||||
|
||||
get urlWithTimestamp(): string {
|
||||
return `${this.url}&t=${+new Date()}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user