Compare commits

...

8 Commits

Author SHA1 Message Date
Johnpaul Chiwetelu
8ddc8739bb fix: clean up orphaned keybindings on load
Filter out stored keybindings referencing commands that are no longer
registered (e.g. from removed extensions) during registerUserKeybindings().
Persist the cleanup to remove orphans from storage.
2026-03-03 16:25:23 +01:00
Johnpaul Chiwetelu
16119dfcd2 fix: allow cursor positioning in painter opacity input (#9348) 2026-03-03 10:14:34 +01:00
Terry Jia
a6f1b1cf90 fix: sync subgraph name on double-click title rename (#9353)
## Summary
The Vue renderer's title editing path (NodeHeader →
useNodeEventHandlers) only updated node.title but not subgraph.name, so
the breadcrumb didn't reflect the new name when entering the subgraph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9353-fix-sync-subgraph-name-on-double-click-title-rename-3186d73d365081e2bc54f19ecd421ac0)
by [Unito](https://www.unito.io)
2026-03-02 20:15:21 -08:00
Alexander Brown
c95d32249b fix: Custom Combo options display in Nodes 2.0 (#9324)
## Summary

Keep the value in the store instead of in the closure.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9324-fix-Custom-Combo-options-display-in-Nodes-2-0-3166d73d3650814db361c41ebdb1d222)
by [Unito](https://www.unito.io)
2026-03-02 19:23:01 -08:00
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
Terry Jia
dccf68ddb7 fix: improve painter cursor performance by bypassing Vue reactivity (#9339)
## Summary
Previously painter has node performance issue. 
Use direct DOM manipulation for cursor position updates instead of
reactive refs, and add will-change-transform for GPU layer promotion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9339-fix-improve-painter-cursor-performance-by-bypassing-Vue-reactivity-3176d73d365081d88b23d26e774cebf5)
by [Unito](https://www.unito.io)
2026-03-02 21:18:48 -05:00
Terry Jia
117448fba4 fix: stop pointer events on audio widgets to prevent node drag (#9329)
## Summary

Audio player and record widgets were missing @pointerdown.stop, causing
node drag when interacting with the timeline or controls.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/061a9ad2-0cc2-45f8-aea0-d45e3a2912b9


after


https://github.com/user-attachments/assets/a510c50a-65b8-4944-9480-b53cbe61c7da

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9329-fix-stop-pointer-events-on-audio-widgets-to-prevent-node-drag-3176d73d36508140b236c61e83954f5c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-02 20:43:25 -05:00
Terry Jia
da77227cf2 fix: clear combo widget value when removing image preview (#9323)
## Summary
The X button on image preview in VueNodes mode only cleared the stored
outputs but left the combo widget value intact, causing the old image to
persist across workflow runs and page refreshes.

## Screenshots (if applicable)
Before

https://github.com/user-attachments/assets/e2146ed1-5d79-41d6-946c-b30667ffac6a

After


https://github.com/user-attachments/assets/359b81fa-acc9-4711-9cee-62c230086f0c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9323-fix-clear-combo-widget-value-when-removing-image-preview-3166d73d3650816db867eba49b8aeb6c)
by [Unito](https://www.unito.io)
2026-03-02 20:26:44 -05:00
20 changed files with 316 additions and 49 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

@@ -28,8 +28,9 @@
/>
<div
v-show="cursorVisible"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
:style="cursorStyle"
ref="cursorEl"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)] will-change-transform"
:style="cursorSizeStyle"
/>
</div>
</div>
@@ -141,7 +142,7 @@
max="100"
step="1"
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
@click.prevent
@click.stop
@change="
(e) => {
const val = Math.min(
@@ -281,6 +282,7 @@ const { nodeId } = defineProps<{
const modelValue = defineModel<string>({ default: '' })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const cursorEl = useTemplateRef<HTMLElement>('cursorEl')
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
const { width: controlsWidth } = useElementSize(controlsEl)
const compact = computed(
@@ -296,8 +298,6 @@ const {
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
@@ -309,7 +309,7 @@ const {
handlePointerLeave,
handleInputImageLoad,
handleClear
} = usePainter(nodeId, { canvasEl, modelValue })
} = usePainter(nodeId, { canvasEl, cursorEl, modelValue })
const canvasContainerStyle = computed(() => ({
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
@@ -318,16 +318,10 @@ const canvasContainerStyle = computed(() => ({
: backgroundColor.value
}))
const cursorStyle = computed(() => {
const size = displayBrushSize.value
const x = cursorX.value - size / 2
const y = cursorY.value - size / 2
return {
width: `${size}px`,
height: `${size}px`,
transform: `translate(${x}px, ${y}px)`
}
})
const cursorSizeStyle = computed(() => ({
width: `${displayBrushSize.value}px`,
height: `${displayBrushSize.value}px`
}))
const brushOpacityPercent = computed({
get: () => Math.round(brushOpacity.value * 100),

View File

@@ -27,11 +27,12 @@ export const PAINTER_TOOLS: Record<string, PainterTool> = {
interface UsePainterOptions {
canvasEl: Ref<HTMLCanvasElement | null>
cursorEl: Ref<HTMLElement | null>
modelValue: Ref<string>
}
export function usePainter(nodeId: string, options: UsePainterOptions) {
const { canvasEl, modelValue } = options
const { canvasEl, cursorEl, modelValue } = options
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
@@ -41,8 +42,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
const canvasWidth = ref(512)
const canvasHeight = ref(512)
const cursorX = ref(0)
const cursorY = ref(0)
const cursorVisible = ref(false)
const inputImageUrl = ref<string | null>(null)
@@ -518,8 +517,10 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
}
function updateCursorPos(e: PointerEvent) {
cursorX.value = e.offsetX
cursorY.value = e.offsetY
const el = cursorEl.value
if (!el) return
const size = displayBrushSize.value
el.style.transform = `translate(${e.offsetX - size / 2}px, ${e.offsetY - size / 2}px)`
}
function handlePointerDown(e: PointerEvent) {
@@ -760,8 +761,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,

View File

@@ -6,6 +6,7 @@ import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length || !this.graph) return
@@ -74,17 +75,25 @@ function onCustomComboCreated(this: LGraphNode) {
function addOption(node: LGraphNode) {
if (!node.widgets) return
const newCount = node.widgets.length - 1
node.addWidget('string', `option${newCount}`, '', () => {})
const widget = node.widgets.at(-1)
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let value = ''
Object.defineProperty(widget, 'value', {
get() {
return value
return useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)?.value
},
set(v) {
value = v
set(v: string) {
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)
if (state) state.value = v
updateCombo()
if (!node.widgets) return
const lastWidget = node.widgets.at(-1)

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

@@ -0,0 +1,130 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import type { Keybinding } from '@/platform/keybindings/types'
import { useCommandStore } from '@/stores/commandStore'
const mockSettingState = vi.hoisted(() => ({
newBindings: [] as Keybinding[],
unsetBindings: [] as Keybinding[],
setMany: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Keybinding.NewBindings')
return mockSettingState.newBindings
if (key === 'Comfy.Keybinding.UnsetBindings')
return mockSettingState.unsetBindings
return []
}),
setMany: mockSettingState.setMany
}))
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: null }
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({ dialogStack: [] }))
}))
describe('keybindingService - orphaned keybinding cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
mockSettingState.newBindings = []
mockSettingState.unsetBindings = []
})
function registerCommand(commandId: string) {
useCommandStore().registerCommand({ id: commandId, function: vi.fn() })
}
it('should skip orphaned new bindings referencing unregistered commands', () => {
registerCommand('Registered.Command')
mockSettingState.newBindings = [
{ commandId: 'Registered.Command', combo: { key: 'A', ctrl: true } },
{
commandId: 'Removed.Extension.Command',
combo: { key: 'B', alt: true }
}
]
const service = useKeybindingService()
service.registerCoreKeybindings()
service.registerUserKeybindings()
const keybindingStore = useKeybindingStore()
expect(
keybindingStore.getKeybindingsByCommandId('Registered.Command')
).toHaveLength(1)
expect(
keybindingStore.getKeybindingsByCommandId('Removed.Extension.Command')
).toHaveLength(0)
})
it('should skip orphaned unset bindings referencing unregistered commands', () => {
registerCommand('Registered.Command')
const registeredBinding: Keybinding = {
commandId: 'Registered.Command',
combo: { key: 'A', ctrl: true }
}
mockSettingState.unsetBindings = [
registeredBinding,
{
commandId: 'Removed.Extension.Command',
combo: { key: 'B', alt: true }
}
]
const keybindingStore = useKeybindingStore()
const unsetSpy = vi.spyOn(keybindingStore, 'unsetKeybinding')
const service = useKeybindingService()
service.registerCoreKeybindings()
service.registerUserKeybindings()
expect(unsetSpy).toHaveBeenCalledTimes(1)
expect(unsetSpy.mock.calls[0][0].commandId).toBe('Registered.Command')
})
it('should persist cleanup when orphaned bindings are found', () => {
registerCommand('Registered.Command')
mockSettingState.newBindings = [
{ commandId: 'Registered.Command', combo: { key: 'A', ctrl: true } },
{
commandId: 'Removed.Extension.Command',
combo: { key: 'B', alt: true }
}
]
const service = useKeybindingService()
service.registerCoreKeybindings()
service.registerUserKeybindings()
expect(mockSettingState.setMany).toHaveBeenCalledTimes(1)
})
it('should not persist when no orphaned bindings exist', () => {
registerCommand('Registered.Command')
mockSettingState.newBindings = [
{ commandId: 'Registered.Command', combo: { key: 'A', ctrl: true } }
]
const service = useKeybindingService()
service.registerCoreKeybindings()
service.registerUserKeybindings()
expect(mockSettingState.setMany).not.toHaveBeenCalled()
})
})

View File

@@ -121,11 +121,20 @@ export function useKeybindingService() {
function registerUserKeybindings() {
const unsetBindings = settingStore.get('Comfy.Keybinding.UnsetBindings')
for (const keybinding of unsetBindings) {
const registeredUnsetBindings = unsetBindings.filter((kb) =>
commandStore.isRegistered(kb.commandId)
)
for (const keybinding of registeredUnsetBindings) {
keybindingStore.unsetKeybinding(new KeybindingImpl(keybinding))
}
const newBindings = settingStore.get('Comfy.Keybinding.NewBindings')
for (const keybinding of newBindings) {
const registeredNewBindings = newBindings.filter((kb) =>
commandStore.isRegistered(kb.commandId)
)
for (const keybinding of registeredNewBindings) {
if (
isCloud &&
keybinding.commandId === 'Workspace.ToggleBottomPanelTab.logs-terminal'
@@ -134,6 +143,13 @@ export function useKeybindingService() {
}
keybindingStore.addUserKeybinding(new KeybindingImpl(keybinding))
}
const hadOrphans =
registeredUnsetBindings.length < unsetBindings.length ||
registeredNewBindings.length < newBindings.length
if (hadOrphans) {
void persistUserKeybindings()
}
}
async function persistUserKeybindings() {

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

@@ -243,7 +243,15 @@ const handleDownload = () => {
const handleRemove = () => {
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
nodeOutputStore.removeNodeOutputs(props.nodeId)
if (node) {
node.imgs = undefined
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
imageWidget.value = ''
}
}
}
const setCurrentIndex = (index: number) => {

View File

@@ -93,6 +93,11 @@ function useNodeEventHandlersIndividual() {
// Update the node title in LiteGraph for persistence
node.title = newTitle
// If this is a subgraph node, sync the subgraph name for breadcrumb reactivity
if (node.isSubgraphNode?.()) {
node.subgraph.name = newTitle
}
}
/**

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative">
<div class="relative" @pointerdown.stop>
<div class="mb-4">
<Button
class="text-base-foreground w-full border-0 bg-secondary-background hover:bg-secondary-background-hover"

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

@@ -1,5 +1,5 @@
<template>
<div class="relative">
<div class="relative" @pointerdown.stop>
<div
v-if="!hideWhenEmpty || modelValue"
class="bg-component-node-widget-background box-border flex gap-4 items-center justify-start relative rounded-lg w-full h-16 px-4 py-0"

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