Join pre/prompt/post for Comfy, stop zero-size generates snapping to a small square, and give the phone library a real closeable preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-02 20:29:44 -05:00
co-authored by Cursor
parent d471d1bdd6
commit 369eb595d7
11 changed files with 76 additions and 28 deletions
+3 -3
View File
@@ -6686,7 +6686,7 @@ async function editImage() {
if (inputStillId.value) body.append('stillId', inputStillId.value)
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
body.append('passMode', imagePassMode.value)
if (imageLoraStack.value.length) body.append('loraStack', JSON.stringify(imageLoraStack.value))
body.append('loraStack', JSON.stringify(imageLoraStack.value))
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const started = await $fetch<{
jobId: string
@@ -6942,7 +6942,7 @@ async function editImageV2() {
return
}
}
if (v2Stack.length) body.append('loraStack', JSON.stringify(v2Stack))
body.append('loraStack', JSON.stringify(v2Stack))
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const started = await $fetch<{
jobId: string
@@ -7154,7 +7154,7 @@ async function generate() {
const shotLoraStacksList = shots
? shots.map(shot => shotLoraStacks.value[shot.n] || [])
: [shotLoraStacks.value[1] || [], ...queued.map(item => item.loraStack || [])]
if (videoLoraStack.value.length) body.append('loraStack', JSON.stringify(videoLoraStack.value))
body.append('loraStack', JSON.stringify(videoLoraStack.value))
body.append('shotLoraStacks', JSON.stringify(shotLoraStacksList))
if (activeDraftId) body.append('draftId', activeDraftId)
if (queued.length) {
+25 -2
View File
@@ -200,8 +200,23 @@
</template>
</section>
<aside class="glass w-full shrink-0 overflow-y-auto rounded-3xl p-4 sm:p-5 lg:h-full lg:w-[22rem]">
<aside
class="glass overflow-y-auto p-4 sm:p-5"
:class="focused
? 'fixed inset-0 z-[60] rounded-none lg:static lg:z-auto lg:h-full lg:w-[22rem] lg:shrink-0 lg:rounded-3xl'
: 'hidden w-full shrink-0 rounded-3xl lg:block lg:h-full lg:w-[22rem]'"
>
<template v-if="focused">
<div class="mb-3 flex items-center justify-between lg:hidden">
<p class="text-sm text-zinc-400">Preview</p>
<button
type="button"
class="rounded-full border border-white/20 bg-zinc-950 px-3 py-1.5 text-sm text-amber-100"
@click="clearFocus"
>
Close
</button>
</div>
<div class="relative overflow-hidden rounded-2xl bg-zinc-900">
<span v-if="detailsHidden" class="sensitive-glow block aspect-video w-full" />
<video
@@ -494,7 +509,7 @@ const previewVideo = computed(() => {
const previewStillSrc = computed(() => {
if (focused.value?.kind !== 'still' || !focused.value.stillGroup) return ''
const id = focusedPartId.value || focused.value.stillGroup.head.id
return `/api/library/stills/${id}?thumb=1`
return `/api/library/stills/${id}`
})
const focusedParts = computed(() => {
if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
@@ -719,6 +734,13 @@ function clearSelection() {
lastSelectedKey.value = ''
}
function clearFocus() {
focusedKey.value = ''
focusedPartId.value = ''
detailsHidden.value = false
cancelRename()
}
function selectAll() {
selectedKeys.value = entries.value.map(entry => entry.key)
lastSelectedKey.value = selectedKeys.value.at(-1) || ''
@@ -978,6 +1000,7 @@ function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
if (pendingDelete.value) pendingDelete.value = null
else if (renaming.value) cancelRename()
else if (focusedKey.value) clearFocus()
else clearSelection()
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) {
+4 -2
View File
@@ -4,6 +4,7 @@ import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/uti
import { imageDimensions } from '~/server/utils/resolution'
import { parseImageAspect } from '~/utils/imageAspect'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImagePassMode, parseImageScaleToTotalPixels } from '~/utils/generationPresets'
import { joinPromptParts } from '~/utils/promptParts'
function parsePasses(raw: string | undefined) {
if (!raw) return [] as { prompt: string }[]
@@ -33,16 +34,17 @@ export default defineEventHandler(async (event) => {
}
}
const prompt = (fields.prompt || '').trim()
const promptMid = (fields.prompt || '').trim()
const promptPre = (fields.promptPre || '').trim()
const promptPost = (fields.promptPost || '').trim()
const prompt = joinPromptParts(promptPre, promptMid, promptPost)
const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect)
if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
}
if (!prompt && aspect === 'auto') {
if (!promptMid && aspect === 'auto') {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
}
if (extraPasses.some(item => !item.prompt)) {
+2 -2
View File
@@ -4,7 +4,7 @@ import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLora
import { clampVideoCfg } from '~/utils/generationPresets'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, videoEngineOf } from '~/utils/videoModels'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
import { composePromptParts, persistPromptWrappers } from '~/utils/promptParts'
import { composePromptParts, joinPromptParts, persistPromptWrappers } from '~/utils/promptParts'
import type { QueuedExtension } from '~/server/utils/library'
function parseDuration(raw: unknown) {
@@ -150,7 +150,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
}
const resolvedLocks = resolveGlobalLocks(fields.globalLocks || '', promptMid)
const prompt = resolvedLocks.prompt
const prompt = joinPromptParts(promptPre, resolvedLocks.prompt, promptPost)
const globalLocks = resolvedLocks.locks
const extensions = parseExtensions(fields.extensions)
const permanenceRefs = parseFamilyPermanenceRefs(fields.permanenceRefs)
+4 -2
View File
@@ -26,6 +26,7 @@ import {
type ImageV2Task
} from '~/utils/imageV2'
import { isXaigenStudio } from '~/utils/videoModels'
import { joinPromptParts } from '~/utils/promptParts'
import { listStudioLoras, parsePostedLoraStack, persistLoraFields, assertImageV2LoraStack } from '~/server/utils/loras'
import { getStill, rememberInputStill, stillPath } from '~/server/utils/library'
import { existsSync, readFileSync } from 'node:fs'
@@ -135,13 +136,14 @@ export default defineEventHandler(async (event) => {
fields.task,
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene'
)
const prompt = String(fields.prompt || '').trim()
const promptMid = String(fields.prompt || '').trim()
const promptPre = String(fields.promptPre || '').trim()
const promptPost = String(fields.promptPost || '').trim()
const prompt = joinPromptParts(promptPre, promptMid, promptPost)
const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect)
if (!prompt && (mode === 'generate' || aspect === 'auto')) {
if (!promptMid && (mode === 'generate' || aspect === 'auto')) {
throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
}
if (extraPasses.length && (mode === 'refine' || mode === 'generate')) {
+2 -2
View File
@@ -8,7 +8,7 @@ import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames'
import { composePromptParts } from '~/utils/promptParts'
import { joinPromptParts } from '~/utils/promptParts'
export type EditImageFile = { filename: string; data: Buffer; type?: string }
@@ -68,7 +68,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (index > 0 && library.stopAfterCurrent === true) break
const mid = (batch || index === 0) ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
const prompt = composePromptParts(library.promptPre || '', mid, library.promptPost || '')
const prompt = joinPromptParts(library.promptPre, mid, library.promptPost)
const last = index === prompts.length - 1
if (index > 0) {
await ensureComfyReady((status) => {
+4 -3
View File
@@ -8,7 +8,7 @@ import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames'
import { composePromptParts } from '~/utils/promptParts'
import { joinPromptParts } from '~/utils/promptParts'
import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2'
import type { EditImageFile } from '~/server/utils/imageChain'
@@ -195,7 +195,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
mode: params.mode,
engine,
task: params.task,
prompt: composePromptParts(library.promptPre || '', prompt, library.promptPost || ''),
prompt: joinPromptParts(library.promptPre, prompt, library.promptPost),
negative: params.negative,
imageAName: uploaded?.name,
imageBName: uploadedRef?.name,
@@ -211,13 +211,14 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
megapixels: params.megapixels,
width: params.width,
height: params.height,
aspect: params.aspect,
turbo: params.turbo === true,
filenamePrefix: chainTotal > 1 ? `${comfyJobPrefix(job.id)}_p${index + 1}` : comfyJobPrefix(job.id),
kreaUnetName: krea?.unet,
kreaClipName: krea?.clip,
kreaVaeName: krea?.vae,
kreaConceptLora: krea?.conceptLora,
loraStack: params.loraStack
loraStack: params.loraStack?.length ? params.loraStack : (library.loraStack || undefined)
})
const queued = await queuePrompt(built.graph, job.clientId)
job.promptId = queued.prompt_id
+3 -2
View File
@@ -70,6 +70,7 @@ export interface ImageV2BuildParams {
strength?: number
width?: number
height?: number
aspect?: string
turbo?: boolean
snofsModel?: number
snofsClip?: number
@@ -458,7 +459,7 @@ function buildKreaGenerateWorkflow(params: ImageV2BuildParams) {
const graph = structuredClone(kreaGenerateTemplate) as WorkflowGraph
const prompt = composeImageV2Prompt('generate', 't2i', params.prompt)
const negative = String(params.negative || '')
const size = clampImageV2Size(params.width, params.height)
const size = clampImageV2Size(params.width, params.height, params.aspect)
const steps = clampImageSteps(params.steps, IMAGE_V2_KREA_STEPS)
const cfg = clampImageCfg(params.cfg, IMAGE_V2_KREA_CFG)
const workflowFile = IMAGE_V2_KREA_GENERATE_WORKFLOW
@@ -520,7 +521,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
const steps = clampImageSteps(params.steps, 24)
const cfg = clampImageCfg(params.cfg, 4)
const strength = refine ? clampImageV2Denoise(params.strength, IMAGE_V2_DENOISE_DEFAULT) : undefined
const size = generate ? clampImageV2Size(params.width, params.height) : null
const size = generate ? clampImageV2Size(params.width, params.height, params.aspect) : null
const workflowFile = imageV2WorkflowFile(params.mode, engine)
if (!generate) setInput(graph, LOAD_A, 'image', params.imageAName || '')
+7 -4
View File
@@ -22,7 +22,7 @@ import {
updateShotQueue
} from '~/server/utils/shotQueue'
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
import { composePromptParts, resolvePromptWrappers } from '~/utils/promptParts'
import { composePromptParts, joinPromptParts, resolvePromptWrappers } from '~/utils/promptParts'
import type { QueuedExtension } from '~/server/utils/library'
import { persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras'
import { readLoraStack, resolveLoraStack } from '~/utils/loras'
@@ -150,7 +150,10 @@ export async function queueMiniMax(
job.status = 'uploading'
const chainIndex = job.library?.chainIndex || 0
const graphId = chainIndex > 0 ? workflowForExtension(params.workflow) : params.workflow
if (job.library) Object.assign(job.library, persistLoraFields(params.loraStack || params.loraName || job.library.loraStack || job.library.loraName))
if (job.library) {
const stack = resolveLoraStack(job.library.loraStack || job.library.loraName, params.loraStack || params.loraName)
if (stack.length) Object.assign(job.library, persistLoraFields(stack))
}
const engineName = isLtxWorkflow(graphId) ? 'LTX-2.3' : 'MiniMax H3'
const hasImage = Boolean(params.image?.data?.length)
const uploading = !hasImage
@@ -188,7 +191,7 @@ export async function queueMiniMax(
const shotIndex = job.library?.chainIndex || 0
const composedPrompt = composeShotPrompt({
globalLocks: job.library?.globalLocks || params.globalLocks,
prompt: composePromptParts(job.library?.promptPre || '', params.prompt, job.library?.promptPost || ''),
prompt: joinPromptParts(job.library?.promptPre, params.prompt, job.library?.promptPost),
shotIndex,
familyRefs: job.library?.permanenceRefs || params.permanenceRefs,
shotRefs: job.library?.shotPermanenceRefs?.[shotIndex] || params.shotPermanenceRefs?.[shotIndex]
@@ -417,7 +420,7 @@ export async function continueQueuedExtensions(
break
}
await queueMiniMax(job, {
prompt: ext.prompt,
prompt: joinPromptParts(wrappers.promptPre, ext.prompt, wrappers.promptPost),
image: { filename: 'last_frame.png', data: frame, type: 'image/png' },
width: params.width,
height: params.height,
+1 -1
View File
@@ -126,7 +126,7 @@ export function clampImageV2Denoise(raw: unknown, fallback = IMAGE_V2_DENOISE_DE
export function snapImageV2Side(raw: unknown, fallback = IMAGE_V2_GENERATE_WIDTH) {
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
if (!Number.isFinite(value) || value <= 0) return fallback
return IMAGE_V2_SIZE_SIDES.reduce((best, side) => (
Math.abs(side - value) < Math.abs(best - value) ? side : best
), IMAGE_V2_SIZE_SIDES[0])
+19 -3
View File
@@ -15,6 +15,16 @@ export function composePromptParts(pre: string, prompt: string, post: string) {
.join('\n\n')
}
/** Combine wrappers for Comfy. If mid already includes pre/post, do not wrap twice. */
export function joinPromptParts(pre?: string, mid?: string, post?: string) {
const parts = restorePromptParts(mid || '', {
pre: pre || '',
prompt: mid || '',
post: post || ''
})
return composePromptParts(parts.pre, parts.prompt, parts.post)
}
export function applyPromptParts(parts: PromptParts, middle: string) {
return composePromptParts(parts.pre, middle, parts.post)
}
@@ -45,7 +55,7 @@ function hasWrapperKey(item: object | null | undefined, key: 'promptPre' | 'prom
return Boolean(item && Object.prototype.hasOwnProperty.call(item, key))
}
/** First source that actually sent the key wins, including empty string. Omitted keys fall through. */
/** First non-empty wrapper wins. Blank strings fall through so a shot can inherit family pre/post. */
export function resolvePromptWrappers(
...sources: Array<PromptWrapperFields | null | undefined>
) {
@@ -56,13 +66,19 @@ export function resolvePromptWrappers(
for (const source of sources) {
if (!source) continue
if (!sawPre && hasWrapperKey(source, 'promptPre') && source.promptPre != null) {
promptPre = sanitizePromptPart(source.promptPre)
const value = sanitizePromptPart(source.promptPre)
if (value.trim()) {
promptPre = value
sawPre = true
}
}
if (!sawPost && hasWrapperKey(source, 'promptPost') && source.promptPost != null) {
promptPost = sanitizePromptPart(source.promptPost)
const value = sanitizePromptPart(source.promptPost)
if (value.trim()) {
promptPost = value
sawPost = true
}
}
if (sawPre && sawPost) break
}
return { promptPre, promptPost }