Clarify output image actions and preserve user VRAM policy

This commit is contained in:
Towsty
2026-09-09 16:38:48 -05:00
parent 6fb87273d7
commit 9b1d8ae798
4 changed files with 97 additions and 63 deletions
+2 -2
View File
@@ -7,6 +7,6 @@ The September 9 investigation found two separate problems:
New video batches now default to automatic continuation. Its checkbox stays outside the settings drawer. Paused batches show “Run remaining”; the bounded action says “Run only shot N”. Resume restores automatic continuation in both the Studio row and shot queue and passes the refreshed run-all policy to the dispatcher. Explicit one-shot / next-three actions remain bounded. Errors still stop the batch instead of silently skipping failed shots. New video batches now default to automatic continuation. Its checkbox stays outside the settings drawer. Paused batches show “Run remaining”; the bounded action says “Run only shot N”. Resume restores automatic continuation in both the Studio row and shot queue and passes the refreshed run-all policy to the dispatcher. Explicit one-shot / next-three actions remain bounded. Errors still stop the batch instead of silently skipping failed shots.
For the observed Windows HostBuffer failure, the connector now prepares Comfy's supported `--disable-dynamic-vram` launch flag when that installed version supports it. This selects estimate-based loading instead of AIMDO dynamic loading. It changes memory management, not the generation graph, handoff frame, refinement, model precision, or source segments. It may change loading speed and memory use; GPU inference has not been tested. `COMFY_DYNAMIC_VRAM=true` on the connector opts back into upstream defaults. The attempted HostBuffer workaround in `badf59d` forced `--disable-dynamic-vram`. The user subsequently reported VRAM allocation failures and a large video slowdown. Their correction in `6fb8727` restores a pass-through memory policy: launch flags are left exactly as the user configured them. The connector must not automatically disable dynamic VRAM, pinned memory, asynchronous offload, or change precision as a retry strategy. The original intermittent HostBuffer fault remains separate from the batch-continuation fix.
The memory change takes effect only after the connector is reloaded and Comfy is next started. Do not restart either during an active render. No running job or existing queue was changed during diagnosis. The checkbox default applies to new batches; use “Run remaining” on an existing paused batch after deploying the UI fix. A policy change takes effect only after the connector is reloaded and Comfy is next started. A Coolify deployment or replacing the module on disk does not update an existing Node import or Python process. Do not restart either during an active render. The checkbox default applies to new batches; use “Run remaining” on an existing paused batch after deploying the UI fix.
+37 -58
View File
@@ -1609,6 +1609,7 @@
<button <button
type="button" type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70" class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
title="Replace only the input image; keep the current prompt and settings"
@click="useEditAsInput" @click="useEditAsInput"
> >
Use as input still Use as input still
@@ -1617,6 +1618,7 @@
type="button" type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70 disabled:opacity-50" class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70 disabled:opacity-50"
:disabled="usingReferenceBusy" :disabled="usingReferenceBusy"
title="Replace only the reference photo; keep the input image, prompt, and settings"
@click="useOutputAsFluxReference" @click="useOutputAsFluxReference"
> >
{{ usingReferenceBusy ? 'Loading reference…' : 'Use as reference' }} {{ usingReferenceBusy ? 'Loading reference…' : 'Use as reference' }}
@@ -1631,17 +1633,12 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70" class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70 disabled:opacity-50"
@click="sendToEdit" title="Replace the input image, prompt, and generation settings with this image's saved setup"
:disabled="usingInputSettingsBusy"
@click="useOutputWithSettings"
> >
Send to Edit {{ usingInputSettingsBusy ? 'Loading settings…' : 'Use as input' }}
</button>
<button
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="sendToRefine"
>
Send to Refine
</button> </button>
<button <button
v-if="backupStudioEnabled" v-if="backupStudioEnabled"
@@ -6448,7 +6445,7 @@ function applyStillSettings(still: LibraryStill) {
persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value) persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value)
v2Engine.value = parseImageV2Engine(settings?.engine, 'flux') v2Engine.value = parseImageV2Engine(settings?.engine, 'flux')
if (!settings) return if (!settings) return
if (typeof settings.negative === 'string') v2Negative.value = settings.negative v2Negative.value = settings.negative || ''
if (settings.steps != null) v2Steps.value = clampImageSteps(settings.steps, IMAGE_V2_STEPS_DEFAULT) if (settings.steps != null) v2Steps.value = clampImageSteps(settings.steps, IMAGE_V2_STEPS_DEFAULT)
if (settings.cfg != null) v2Cfg.value = clampImageCfg(settings.cfg, IMAGE_V2_CFG_DEFAULT) if (settings.cfg != null) v2Cfg.value = clampImageCfg(settings.cfg, IMAGE_V2_CFG_DEFAULT)
if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1) if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
@@ -6458,12 +6455,12 @@ function applyStillSettings(still: LibraryStill) {
aspect.value = parseImageAspect(settings.aspect) aspect.value = parseImageAspect(settings.aspect)
if (mode === 'generate') { if (mode === 'generate') {
const size = generateSizeForAspect(aspect.value) const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width v2Width.value = settings.width && settings.width > 0 ? settings.width : size.width
v2Height.value = size.height v2Height.value = settings.height && settings.height > 0 ? settings.height : size.height
} }
if (mode === 'compose' && settings.task) v2Task.value = settings.task if (mode === 'compose' && settings.task) v2Task.value = settings.task
imageV2LoraStack.value = mergeLegacyImageV2Loras( imageV2LoraStack.value = mergeLegacyImageV2Loras(
settings.loraStack?.length || settings.loraName ? readLoraStack(settings) : imageV2LoraStack.value, readLoraStack(settings),
{ {
engine: v2Engine.value, engine: v2Engine.value,
mode, mode,
@@ -6482,12 +6479,10 @@ function applyStillSettings(still: LibraryStill) {
if (!settings) return if (!settings) return
if (settings.steps != null) editSteps.value = clampImageSteps(settings.steps) if (settings.steps != null) editSteps.value = clampImageSteps(settings.steps)
if (settings.cfg != null) editCfg.value = clampImageCfg(settings.cfg) if (settings.cfg != null) editCfg.value = clampImageCfg(settings.cfg)
if (typeof settings.negative === 'string') editNegative.value = settings.negative editNegative.value = settings.negative || ''
editScaleToTotalPixels.value = settings.scaleToTotalPixels === true editScaleToTotalPixels.value = settings.scaleToTotalPixels === true
if (settings.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels) if (settings.scaleMegapixels != null) editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels)
if (settings.loraStack?.length || settings.loraName) {
imageLoraStack.value = filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value) imageLoraStack.value = filterLoraStackForStudio(readLoraStack(settings), ltxEnabled.value)
}
aspect.value = parseImageAspect(settings.aspect) aspect.value = parseImageAspect(settings.aspect)
} }
@@ -7303,34 +7298,6 @@ async function ensureRefineCanvas() {
} }
} }
async function sendToEdit() {
if (!editResultUrl.value) return
clearEditPasses()
studioMode.value = 'editv2'
outputFocus.value = 'edit'
try {
await useEditAsInput()
setV2Mode('edit')
toast('Output is Still A. Write an edit prompt.')
} catch {
toast('Could not send that still to Edit')
}
}
async function sendToRefine() {
if (!editResultUrl.value) return
clearEditPasses()
studioMode.value = 'editv2'
outputFocus.value = 'edit'
try {
await useEditAsInput()
setV2Mode('refine')
toast('Output is Still A. Paint a mask, then refine.')
} catch {
toast('Could not send that still to Refine')
}
}
async function editImageV2() { async function editImageV2() {
if (editV2Blocked.value) { if (editV2Blocked.value) {
toast(editV2BlockReason.value || 'Load still A first.') toast(editV2BlockReason.value || 'Load still A first.')
@@ -7502,12 +7469,9 @@ async function useOutputAsFluxReference() {
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' }) const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
if (!blob?.size) throw new Error('Empty image') if (!blob?.size) throw new Error('Empty image')
const reference = new File([blob], `${editDownloadName.value || 'reference'}.png`, { type: blob.type || 'image/png' }) const reference = new File([blob], `${editDownloadName.value || 'reference'}.png`, { type: blob.type || 'image/png' })
selectStudioKind('image')
setV2Engine('flux')
resetImage()
v2Mode.value = editPassQueue.value.length ? 'iterate' : 'generate'
setEditRef(reference) setEditRef(reference)
toast('Added to Flux Klein reference photo. Your prompt will generate a new image.') scrollToStudioInput()
toast('Reference photo updated. Your input image, prompt, and settings were kept.')
} catch { } catch {
toast('Could not load that image as a reference') toast('Could not load that image as a reference')
} finally { } finally {
@@ -7515,21 +7479,36 @@ async function useOutputAsFluxReference() {
} }
} }
const usingInputSettingsBusy = ref(false)
async function useOutputWithSettings() {
if (!editResultUrl.value || usingInputSettingsBusy.value) return
usingInputSettingsBusy.value = true
const stillId = stillIdFromLibraryUrl(editResultUrl.value) || lastV2StillId.value
try {
if (!stillId) throw new Error('This image has no saved settings yet.')
if (!stills.value.some(item => item.id === stillId)) await loadLibrary()
const still = stills.value.find(item => item.id === stillId)
if (!still) throw new Error('Saved settings for this image are unavailable.')
await useStillAsInput(still)
} catch (error: any) {
toast(error?.message || 'Could not load this image and its settings')
} finally {
usingInputSettingsBusy.value = false
}
}
async function useEditAsInput() { async function useEditAsInput() {
if (!editResultUrl.value) return if (!editResultUrl.value) return
const stillId = lastV2StillId.value || stillIdFromLibraryUrl(editResultUrl.value) const stillId = stillIdFromLibraryUrl(editResultUrl.value) || lastV2StillId.value
const libraryStill = stillId ? stills.value.find(item => item.id === stillId) : undefined
if (libraryStill) {
await useStillAsInput(libraryStill)
return
}
try { try {
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' }) const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
if (!blob || !blob.size) throw new Error('empty still') if (!blob || !blob.size) throw new Error('empty still')
readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }), { readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }), {
stillId: stillId || undefined stillId: stillId || undefined, keepThumbnailPref: true
}) })
toast('Edited still is now the input') scrollToStudioInput()
toast('Input image updated. Your prompt and settings were kept.')
} catch { } catch {
toast('Could not load that still as input') toast('Could not load that still as input')
} }
+6 -2
View File
@@ -2,10 +2,14 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { stableMemoryArgs } from '../scripts/comfy-memory-policy.mjs' import { stableMemoryArgs } from '../scripts/comfy-memory-policy.mjs'
test('Windows uses the supported non-dynamic loader without changing workflow or precision flags', () => { test('memory policy preserves user launch flags and never forces a different VRAM loader', () => {
const args = ['main.py', '--enable-dynamic-vram', '--fast-disk', '--enable-manager', '--fp16-vae'] const args = ['main.py', '--enable-dynamic-vram', '--fast-disk', '--enable-manager', '--fp16-vae']
assert.deepEqual(stableMemoryArgs(args, { platform: 'win32', cliSource: '--disable-dynamic-vram', dynamicVram: 'false' }), ['main.py', '--enable-manager', '--fp16-vae', '--disable-dynamic-vram']) assert.deepEqual(stableMemoryArgs(args, { platform: 'win32', cliSource: '--disable-dynamic-vram', dynamicVram: 'false' }), args)
assert.deepEqual(stableMemoryArgs(args, { platform: 'win32', cliSource: '' }), args) assert.deepEqual(stableMemoryArgs(args, { platform: 'win32', cliSource: '' }), args)
assert.deepEqual(stableMemoryArgs(args, { platform: 'linux', cliSource: '--disable-dynamic-vram' }), args) assert.deepEqual(stableMemoryArgs(args, { platform: 'linux', cliSource: '--disable-dynamic-vram' }), args)
assert.deepEqual(stableMemoryArgs(args, { platform: 'win32', cliSource: '--disable-dynamic-vram', dynamicVram: 'true' }), args) assert.deepEqual(stableMemoryArgs(args, { platform: 'win32', cliSource: '--disable-dynamic-vram', dynamicVram: 'true' }), args)
const defaults = ['main.py', '--enable-manager']
assert.deepEqual(stableMemoryArgs(defaults), defaults)
assert.notEqual(stableMemoryArgs(defaults), defaults)
assert.deepEqual(stableMemoryArgs(['main.py', '--disable-dynamic-vram']), ['main.py', '--disable-dynamic-vram'])
}) })
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
const vue = readFileSync(new URL('../pages/index.vue', import.meta.url), 'utf8')
const script = vue.split('<script setup lang="ts">')[1].split('</script>')[0]
const tree = ts.createSourceFile('index.ts', script, ts.ScriptTarget.Latest, true)
function action(name, scope) {
const node = tree.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === name)
assert.ok(node, `${name} exists`)
const code = ts.transpileModule(node.getText(tree), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
return new Function(...Object.keys(scope), `${code}; return ${name}`)(...Object.values(scope))
}
function fixture() {
const current = { id: 'displayed', settings: { steps: 24 } }
const state = { input: 'existing input', reference: 'existing reference', prompt: 'current prompt', steps: 19, engine: 'flux', mode: 'generate' }
const calls = []
return { state, calls, current, scope: {
editResultUrl: { value: '/api/library/stills/displayed' }, lastV2StillId: { value: 'older-generation' }, editDownloadName: { value: 'displayed' },
stillIdFromLibraryUrl: () => 'displayed', stills: { value: [current, { id: 'older-generation' }] },
usingReferenceBusy: { value: false }, usingInputSettingsBusy: { value: false },
$fetch: async () => new Blob(['pixels'], { type: 'image/png' }), File,
readFile: (file, options) => { state.input = file; calls.push(options) },
setEditRef: file => { state.reference = file },
useStillAsInput: async still => { calls.push(still.id) },
loadLibrary: async () => {}, scrollToStudioInput: () => {}, toast: () => {},
selectStudioKind: () => { throw new Error('Must not change workspace') },
setV2Engine: () => { throw new Error('Must not change engine') },
resetImage: () => { throw new Error('Must not clear input') }
} }
}
test('input-still action loads only pixels and preserves current settings', async () => {
const f = fixture()
await action('useEditAsInput', f.scope)()
assert.ok(f.state.input instanceof File)
assert.deepEqual(f.calls, [{ stillId: 'displayed', keepThumbnailPref: true }])
assert.equal(f.state.prompt, 'current prompt'); assert.equal(f.state.steps, 19)
assert.equal(f.state.reference, 'existing reference')
})
test('reference action changes only the reference, preserving input, prompt, engine and mode', async () => {
const f = fixture()
await action('useOutputAsFluxReference', f.scope)()
assert.ok(f.state.reference instanceof File)
assert.deepEqual({ ...f.state, reference: null }, { input: 'existing input', reference: null, prompt: 'current prompt', steps: 19, engine: 'flux', mode: 'generate' })
})
test('full input action restores the displayed image settings instead of the previous generation', async () => {
const f = fixture()
await action('useOutputWithSettings', f.scope)()
assert.deepEqual(f.calls, ['displayed'])
})