From 099b62edd705963ed2ac00540f2d10aa9752b6a9 Mon Sep 17 00:00:00 2001 From: Towsty Date: Fri, 28 Aug 2026 16:36:15 -0500 Subject: [PATCH] Add a Scale to 1 MP toggle on image edit before Klein. Co-authored-by: Cursor --- pages/index.vue | 47 +++++++++++++++- server/api/edit.post.ts | 16 +++++- server/assets/workflow_flux2_klein_edit.json | 17 +----- server/utils/comfy.ts | 28 ++++++++++ server/utils/generationPresets.ts | 5 +- server/utils/imageChain.ts | 9 ++- server/utils/imageWorkflow.ts | 59 ++++++++++++++++++++ server/utils/studioQueue.ts | 4 ++ utils/generationPresets.ts | 17 ++++++ 9 files changed, 178 insertions(+), 24 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index aff8698..070039e 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -621,6 +621,25 @@

Flux.2 Klein 9B. Shares the desktop GPU with video.

+
-

Graph default is 20 steps, CFG 1, euler. CFG 0–20 (0.1 steps), steps 1–50. A second still switches the two-image branch on.

+

Graph default is 20 steps, CFG 1, euler. CFG 0–20 (0.1 steps), steps 1–50. A second still switches the two-image branch on. Scale to 1 MP runs Scale Image to Total Pixels (lanczos) on both stills before Klein.

@@ -1627,11 +1646,15 @@ import { } from '~/utils/loras' import { clampImageCfg, + clampImageScaleMegapixels, clampImageSteps, clampVideoCfg, IMAGE_CFG_MAX, IMAGE_CFG_MIN, IMAGE_CFG_STEP, + IMAGE_SCALE_MP_MAX, + IMAGE_SCALE_MP_MIN, + IMAGE_SCALE_MP_STEP, IMAGE_STEPS_MAX, IMAGE_STEPS_MIN, VIDEO_CFG_MAX, @@ -1895,6 +1918,8 @@ const editLockedSave = ref(false) const editSteps = ref(20) const editCfg = ref(1) const editNegative = ref('') +const editScaleToTotalPixels = ref(false) +const editScaleMegapixels = ref(1) const editRefFile = ref(null) const editRefPreview = ref('') const editChainStep = ref(1) @@ -2891,6 +2916,10 @@ function onEditCfgNumber() { editCfg.value = clampImageCfg(editCfg.value) } +function onEditScaleMegapixels() { + editScaleMegapixels.value = clampImageScaleMegapixels(editScaleMegapixels.value) +} + function onCfgSlider(event: Event) { cfgTouched.value = true cfg.value = clampCfg(Number((event.target as HTMLInputElement).value)) @@ -2967,7 +2996,9 @@ function currentPresetSnapshot() { settings: { steps: clampImageSteps(editSteps.value), cfg: clampImageCfg(editCfg.value), - negative: editNegative.value + negative: editNegative.value, + scaleToTotalPixels: editScaleToTotalPixels.value, + scaleMegapixels: clampImageScaleMegapixels(editScaleMegapixels.value) } } } @@ -2993,10 +3024,18 @@ function applyGenerationPreset(preset: GenerationPreset) { const skipped = preset.loraStack.length - stack.length if (preset.kind === 'image') { imageLoraStack.value = stack - const settings = preset.settings as { steps?: number; cfg?: number; negative?: string } + const settings = preset.settings as { + steps?: number + cfg?: number + negative?: string + scaleToTotalPixels?: boolean + scaleMegapixels?: number + } if (typeof settings.steps === 'number') editSteps.value = clampImageSteps(settings.steps) if (typeof settings.cfg === 'number') editCfg.value = clampImageCfg(settings.cfg) if (typeof settings.negative === 'string') editNegative.value = settings.negative + editScaleToTotalPixels.value = settings.scaleToTotalPixels === true + editScaleMegapixels.value = clampImageScaleMegapixels(settings.scaleMegapixels) imagePresetId.value = preset.id imagePresetName.value = preset.name } else { @@ -4645,6 +4684,8 @@ async function editImage() { body.append('negative', editNegative.value) body.append('steps', String(clampImageSteps(editSteps.value))) body.append('cfg', String(clampImageCfg(editCfg.value))) + body.append('scaleToTotalPixels', String(editScaleToTotalPixels.value)) + body.append('scaleMegapixels', String(clampImageScaleMegapixels(editScaleMegapixels.value))) body.append('seed', seedInput.value || 'random') body.append('folderId', folderId.value) body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value)) diff --git a/server/api/edit.post.ts b/server/api/edit.post.ts index 30af520..c7e1a1a 100644 --- a/server/api/edit.post.ts +++ b/server/api/edit.post.ts @@ -1,8 +1,8 @@ import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue' import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras' -import { comfyConfigured } from '~/server/utils/comfy' +import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy' import { imageDimensions } from '~/server/utils/resolution' -import { clampImageCfg, clampImageSteps } from '~/utils/generationPresets' +import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImageScaleToTotalPixels } from '~/utils/generationPresets' function parsePasses(raw: string | undefined) { if (!raw) return [] as { prompt: string }[] @@ -66,6 +66,16 @@ export default defineEventHandler(async (event) => { const hideThumbnail = fields.hideThumbnail === 'true' const steps = clampImageSteps(fields.steps) const cfg = clampImageCfg(fields.cfg) + const scaleToTotalPixels = parseImageScaleToTotalPixels(fields.scaleToTotalPixels) + const scaleMegapixels = clampImageScaleMegapixels(fields.scaleMegapixels) + if (scaleToTotalPixels) { + try { + await assertImageScaleToTotalPixelsNode() + } catch (error) { + const status = Number((error as { statusCode?: number })?.statusCode || 0) + if (status === 400) throw error + } + } const seed = fields.seed && fields.seed !== 'random' ? Number(fields.seed) : Math.floor(Math.random() * 2_147_483_647) @@ -130,6 +140,8 @@ export default defineEventHandler(async (event) => { passes, referenceStillId: savedRef?.id, referenceStillFilename: savedRef?.filename, + scaleToTotalPixels, + scaleMegapixels, ...persistLoraFields(loraStack) } }) diff --git a/server/assets/workflow_flux2_klein_edit.json b/server/assets/workflow_flux2_klein_edit.json index c6197a3..e47643a 100644 --- a/server/assets/workflow_flux2_klein_edit.json +++ b/server/assets/workflow_flux2_klein_edit.json @@ -405,21 +405,6 @@ "title": "Empty Flux 2 Latent" } }, - "92:85": { - "inputs": { - "upscale_method": "lanczos", - "megapixels": 1, - "resolution_steps": 1, - "image": [ - "81", - 0 - ] - }, - "class_type": "ImageScaleToTotalPixels", - "_meta": { - "title": "Scale Image to Total Pixels" - } - }, "92:111": { "inputs": { "clip_name": "qwen_3_8b_fp8mixed.safetensors", @@ -562,7 +547,7 @@ "92:129": { "inputs": { "pixels": [ - "92:85", + "81", 0 ], "vae": [ diff --git a/server/utils/comfy.ts b/server/utils/comfy.ts index bac6fdf..fc656f9 100644 --- a/server/utils/comfy.ts +++ b/server/utils/comfy.ts @@ -51,6 +51,34 @@ export async function comfyFetch(path: string, init?: RequestInit) { } } +/** true = installed, false = missing, null = Comfy unreachable / object_info failed */ +export async function comfyHasClassType(classType: string): Promise { + try { + const res = await comfyFetch(`/object_info/${encodeURIComponent(classType)}`, { + signal: AbortSignal.timeout(8000) + }) + if (res.status === 404) return false + if (!res.ok) return null + const body = await res.json().catch(() => null) + if (!body || typeof body !== 'object') return false + const rec = body as Record + if (rec[classType] && typeof rec[classType] === 'object') return true + return String(rec.name || rec.class_type || '') === classType + } catch { + return null + } +} + +export const IMAGE_SCALE_TO_TOTAL_PIXELS = 'ImageScaleToTotalPixels' +export const IMAGE_SCALE_MISSING_MESSAGE = 'Scale Image to Total Pixels (ImageScaleToTotalPixels) is not installed on Beast. Turn Scale to 1 MP off, or install the node.' + +export async function assertImageScaleToTotalPixelsNode() { + const present = await comfyHasClassType(IMAGE_SCALE_TO_TOTAL_PIXELS) + if (present === false) { + throw createError({ statusCode: 400, statusMessage: IMAGE_SCALE_MISSING_MESSAGE }) + } +} + export async function uploadImage(file: { filename: string; data: Buffer; type?: string }, jobId?: string) { const body = new FormData() const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' }) diff --git a/server/utils/generationPresets.ts b/server/utils/generationPresets.ts index d30d50d..9754921 100644 --- a/server/utils/generationPresets.ts +++ b/server/utils/generationPresets.ts @@ -4,6 +4,7 @@ import { ownerRoot } from '~/server/utils/library' import { filterLoraStackForStudio, normalizeLoraStack } from '~/utils/loras' import { clampImageCfg, + clampImageScaleMegapixels, clampImageSteps, clampVideoCfg, normalizePresetName, @@ -89,7 +90,9 @@ function sanitizeImageSettings(raw: unknown): ImagePresetSettings { return { steps: clampImageSteps(rec.steps), cfg: clampImageCfg(rec.cfg), - negative: String(rec.negative || '').slice(0, 2000) + negative: String(rec.negative || '').slice(0, 2000), + scaleToTotalPixels: rec.scaleToTotalPixels === true, + scaleMegapixels: clampImageScaleMegapixels(rec.scaleMegapixels) } } diff --git a/server/utils/imageChain.ts b/server/utils/imageChain.ts index a4f4039..c214112 100644 --- a/server/utils/imageChain.ts +++ b/server/utils/imageChain.ts @@ -1,6 +1,6 @@ import { createJob, emitJob, type Job } from '~/server/utils/jobs' import { ensureComfyReady } from '~/server/utils/comfyLifecycle' -import { getComfyHost, uploadImage, queuePrompt, purgeComfyArtifacts } from '~/server/utils/comfy' +import { assertImageScaleToTotalPixelsNode, getComfyHost, uploadImage, queuePrompt, purgeComfyArtifacts } from '~/server/utils/comfy' import { withImageComfyHost, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy' import { buildEditWorkflow } from '~/server/utils/imageWorkflow' import { ensureComfyLoraNames } from '~/server/utils/loras' @@ -21,6 +21,8 @@ export type EditRunParams = { cfg: number loraName?: string loraStack?: import('~/utils/loras').LoraStackItem[] + scaleToTotalPixels?: boolean + scaleMegapixels?: number } export async function runEdit(job: Job, params: EditRunParams) { @@ -93,6 +95,7 @@ export async function runEdit(job: Job, params: EditRunParams) { progress: 12 }) await ensureComfyLoraNames('image') + if (params.scaleToTotalPixels) await assertImageScaleToTotalPixelsNode() const graph = buildEditWorkflow({ prompt, negative: params.negative, @@ -102,7 +105,9 @@ export async function runEdit(job: Job, params: EditRunParams) { seed, cfg: params.cfg, filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}_p${index + 1}`, - loraStack: params.loraStack || params.loraName + loraStack: params.loraStack || params.loraName, + scaleToTotalPixels: params.scaleToTotalPixels, + scaleMegapixels: params.scaleMegapixels }) const queued = await queuePrompt(graph, job.clientId) job.promptId = queued.prompt_id diff --git a/server/utils/imageWorkflow.ts b/server/utils/imageWorkflow.ts index 8ebbe71..c485b3f 100644 --- a/server/utils/imageWorkflow.ts +++ b/server/utils/imageWorkflow.ts @@ -1,5 +1,7 @@ import template from '../assets/workflow_flux2_klein_edit.json' +import { IMAGE_SCALE_TO_TOTAL_PIXELS } from '~/server/utils/comfy' import { applyOptionalLoraLoaders } from '~/server/utils/loras' +import { clampImageScaleMegapixels } from '~/utils/generationPresets' import type { LoraStackItem } from '~/utils/loras' const IMAGE1 = '76' @@ -16,6 +18,7 @@ const DUAL_NEGATIVE = '92:87' const DUAL_NOISE = '92:105' const DUAL_SCHEDULER = '92:115' const DUAL_CFG = '92:114' +const LOAD_IMAGE = 'LoadImage' type WorkflowNode = { class_type: string; inputs: Record; _meta?: { title?: string } } type WorkflowGraph = Record @@ -31,6 +34,8 @@ export interface EditParams { filenamePrefix?: string loraName?: string loraStack?: LoraStackItem[] + scaleToTotalPixels?: boolean + scaleMegapixels?: number } export function buildEditWorkflow(params: EditParams) { @@ -65,11 +70,63 @@ export function buildEditWorkflow(params: EditParams) { if (dual ? singleNode : dualNode) delete graph[id] } + applyScaleToTotalPixels(graph, Boolean(params.scaleToTotalPixels), params.scaleMegapixels) + // User LoRAs only. Empty stack bypasses the optional LoraLoader (no snofs default). applyOptionalLoraLoaders(graph, params.loraStack || params.loraName, 'image') return graph } +function linkSource(value: unknown): string | null { + return Array.isArray(value) && typeof value[0] === 'string' ? value[0] : null +} + +function stripScaleNodes(graph: WorkflowGraph) { + for (const [id, node] of Object.entries(graph)) { + if (node.class_type !== IMAGE_SCALE_TO_TOTAL_PIXELS) continue + const src = Array.isArray(node.inputs.image) ? node.inputs.image : null + for (const [otherId, other] of Object.entries(graph)) { + if (otherId === id) continue + for (const [key, value] of Object.entries(other.inputs)) { + if (linkSource(value) !== id || !Array.isArray(value)) continue + other.inputs[key] = src ? [src[0], value[1] === 0 ? src[1] : value[1]] : value + } + } + delete graph[id] + } +} + +function insertScaleNodes(graph: WorkflowGraph, megapixels: number) { + const loaders = Object.entries(graph).filter(([, node]) => node.class_type === LOAD_IMAGE) + for (const [id] of loaders) { + const scaleId = `scale:${id}` + if (graph[scaleId]) continue + graph[scaleId] = { + class_type: IMAGE_SCALE_TO_TOTAL_PIXELS, + inputs: { + upscale_method: 'lanczos', + megapixels, + resolution_steps: 1, + image: [id, 0] + }, + _meta: { title: 'Scale Image to Total Pixels' } + } + for (const [otherId, other] of Object.entries(graph)) { + if (otherId === scaleId) continue + for (const [key, value] of Object.entries(other.inputs)) { + if (!Array.isArray(value) || value[0] !== id || value[1] !== 0) continue + other.inputs[key] = [scaleId, 0] + } + } + } +} + +function applyScaleToTotalPixels(graph: WorkflowGraph, enabled: boolean, megapixels?: number) { + stripScaleNodes(graph) + if (!enabled) return + insertScaleNodes(graph, clampImageScaleMegapixels(megapixels)) +} + function setImage(graph: WorkflowGraph, id: string, name: string) { if (graph[id]) graph[id].inputs.image = name } @@ -85,6 +142,8 @@ function setInput(graph: WorkflowGraph, id: string, key: string, value: unknown) export const EDIT_NODE_LABELS: Record = { '76': 'Loading image 1', '81': 'Loading image 2', + 'scale:76': 'Scaling image 1', + 'scale:81': 'Scaling image 2', '75:70': 'Loading Flux.2 Klein', '75:71': 'Loading CLIP', '75:72': 'Loading VAE', diff --git a/server/utils/studioQueue.ts b/server/utils/studioQueue.ts index 200b924..a366b37 100644 --- a/server/utils/studioQueue.ts +++ b/server/utils/studioQueue.ts @@ -47,6 +47,8 @@ export interface StudioJobPayload { passes?: { prompt: string }[] referenceStillId?: string referenceStillFilename?: string + scaleToTotalPixels?: boolean + scaleMegapixels?: number } export interface StudioJob { @@ -656,6 +658,8 @@ async function startStudioEditJob(item: StudioJob) { steps: payload.steps, seed: job.library?.seed || payload.seed, cfg: payload.cfg, + scaleToTotalPixels: payload.scaleToTotalPixels === true, + scaleMegapixels: payload.scaleMegapixels, ...persistLoraFields(payload.loraStack || payload.loraName) }).catch((error) => { const message = error instanceof Error ? error.message : String(error) diff --git a/utils/generationPresets.ts b/utils/generationPresets.ts index 6acdfb7..22032ad 100644 --- a/utils/generationPresets.ts +++ b/utils/generationPresets.ts @@ -19,6 +19,8 @@ export type ImagePresetSettings = { steps?: number cfg?: number negative?: string + scaleToTotalPixels?: boolean + scaleMegapixels?: number } export const IMAGE_STEPS_MIN = 1 @@ -26,6 +28,10 @@ export const IMAGE_STEPS_MAX = 50 export const IMAGE_CFG_MIN = 0 export const IMAGE_CFG_MAX = 20 export const IMAGE_CFG_STEP = 0.1 +export const IMAGE_SCALE_MP_MIN = 0.01 +export const IMAGE_SCALE_MP_MAX = 16 +export const IMAGE_SCALE_MP_STEP = 0.01 +export const IMAGE_SCALE_MP_DEFAULT = 1 export const VIDEO_CFG_MIN = 0 export const VIDEO_CFG_MAX = 20 @@ -44,6 +50,17 @@ export function clampImageCfg(raw: unknown, fallback = 1) { return Math.min(IMAGE_CFG_MAX, Math.max(IMAGE_CFG_MIN, Math.round(snapped * 10) / 10)) } +export function clampImageScaleMegapixels(raw: unknown, fallback = IMAGE_SCALE_MP_DEFAULT) { + const value = Number(raw) + if (!Number.isFinite(value)) return fallback + const snapped = Math.round(value / IMAGE_SCALE_MP_STEP) * IMAGE_SCALE_MP_STEP + return Math.min(IMAGE_SCALE_MP_MAX, Math.max(IMAGE_SCALE_MP_MIN, Math.round(snapped * 100) / 100)) +} + +export function parseImageScaleToTotalPixels(raw: unknown) { + return raw === true || raw === 'true' || raw === '1' || raw === 1 +} + export function clampVideoCfg(raw: unknown, fallback = 1.5) { const value = Number(raw) if (!Number.isFinite(value)) return fallback