Add Image v2 as a sibling of Image edit, with separate Klein graphs.

Keep the v1 Image tab on its dual-branch template. The host agent follows Desktop's live port so Coolify stays on :8198.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 21:22:37 -05:00
co-authored by Cursor
parent 55f8cb1def
commit 21fe2412f9
18 changed files with 1678 additions and 67 deletions
+69
View File
@@ -51,6 +51,13 @@ export interface StudioJobPayload {
referenceStillFilename?: string
scaleToTotalPixels?: boolean
scaleMegapixels?: number
imagePipeline?: 'v1' | 'v2'
v2Mode?: 'edit' | 'compose'
v2Task?: 'scene' | 'identity' | 'outfit' | 'face_lock'
snofsModel?: number
snofsClip?: number
consistencyModel?: number
consistencyClip?: number
}
export interface StudioJob {
@@ -210,6 +217,7 @@ export function summarizeStudioJob(job: StudioJob) {
liveJobId: job.liveJobId,
stillId: job.payload.stillId,
workflow: job.payload.workflow,
imagePipeline: job.payload.imagePipeline || 'v1',
duration: job.payload.duration,
hideThumbnail: job.payload.hideThumbnail,
folderLocked: job.payload.folderLocked === true,
@@ -665,6 +673,7 @@ async function startStudioEditJob(item: StudioJob) {
const { stillPath } = await import('~/server/utils/library')
const { existsSync, readFileSync } = await import('node:fs')
const { createEditLiveJob, runEdit } = await import('~/server/utils/imageChain')
const { runEditV2 } = await import('~/server/utils/imageChainV2')
const payload = item.payload
if (!payload.stillId || !existsSync(stillPath(item.ownerKey, payload.stillId))) {
@@ -684,6 +693,66 @@ async function startStudioEditJob(item: StudioJob) {
}
: null
if (payload.imagePipeline === 'v2') {
const mode = payload.v2Mode === 'compose' ? 'compose' : 'edit'
if (mode === 'compose' && !reference) {
throw new Error('Compose requires image B. Refusing to fall back to one-image generation.')
}
if (mode === 'edit' && reference) {
throw new Error('Edit mode takes one image. Use Compose for two stills.')
}
live = createEditLiveJob({
steps: payload.steps,
hideThumbnail: payload.hideThumbnail,
library: {
ownerKey: item.ownerKey,
folderId: payload.folderId,
hideThumbnail: payload.hideThumbnail,
hideInput: payload.hideInput,
folderLocked: payload.folderLocked,
name: payload.name,
prompt: payload.prompt,
aspect: payload.aspect || 'auto',
width: payload.width,
height: payload.height,
steps: payload.steps,
turbo: payload.turbo === true,
seed: payload.seed || Math.floor(Math.random() * 2_147_483_647),
cfg: payload.cfg,
stillId: payload.stillId,
stillFilename: payload.stillFilename,
familyId: item.familyId,
chainIndex: 0,
chainStep: 1,
chainTotal: 1
}
})
await markStudioLive(item.ownerKey, item.id, live.id)
void runEditV2(live, {
mode,
task: payload.v2Task || 'scene',
image,
reference,
prompt: payload.prompt,
negative: payload.negative || '',
steps: payload.steps,
seed: live.library?.seed || payload.seed,
cfg: payload.cfg,
snofsModel: payload.snofsModel ?? 0.65,
snofsClip: payload.snofsClip ?? 0.35,
consistencyModel: payload.consistencyModel ?? 0.7,
consistencyClip: payload.consistencyClip ?? 0.7,
megapixels: payload.scaleMegapixels ?? 1
}).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
if (live && live.status !== 'error' && live.status !== 'cancelled' && live.status !== 'deferred') {
live.status = 'error'
live.error = message
}
})
return
}
const passes = payload.passes || []
live = createEditLiveJob({
steps: payload.steps,