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:
Dante
2026-03-03 11:56:06 +09:00
committed by GitHub
parent dccf68ddb7
commit 740df0470e
11 changed files with 122 additions and 16 deletions

View File

@@ -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) {

View File

@@ -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) : []

View File

@@ -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}`)
}
}

View File

@@ -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
}

View File

@@ -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,

View 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)
})
})

View 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')
}

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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}`)
})
}

View File

@@ -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()}`
}