Flatten image edit controls and let CFG/steps be typed past the old slider caps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 15:42:59 -05:00
co-authored by Cursor
parent f8db0d626a
commit a2f728fe2e
6 changed files with 151 additions and 100 deletions
+108 -68
View File
@@ -173,21 +173,37 @@
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
Write camera, action, and audio. That is the dynamic action prompt. We concatenate the <span class="font-medium text-zinc-300">&lt;Picture 1&gt;</span> identity prefix in front of it and send the result to <span class="font-medium text-zinc-300">Reference to Video</span>, not Image to Video.
</p>
<textarea
v-model="prompt"
:rows="shotScriptMode || identityPrompting ? 12 : 6"
class="hidden max-h-[min(28rem,70vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:block"
:placeholder="promptPlaceholder"
/>
<button
type="button"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-left md:hidden"
@click="promptDrawerOpen = true"
<div
class="grid gap-3"
:class="studioMode === 'edit' ? 'md:grid-cols-2' : ''"
>
<p v-if="prompt.trim()" class="line-clamp-4 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ prompt }}</p>
<p v-else class="text-sm text-zinc-600">{{ promptPlaceholder }}</p>
<span class="mt-2 inline-block text-xs text-amber-200">{{ prompt.trim() ? 'View / edit prompt' : 'Write prompt' }}</span>
</button>
<div class="min-w-0">
<textarea
v-model="prompt"
:rows="shotScriptMode || identityPrompting ? 12 : 6"
class="hidden max-h-[min(28rem,70vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:block"
:placeholder="promptPlaceholder"
/>
<button
type="button"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-left md:hidden"
@click="promptDrawerOpen = true"
>
<p v-if="prompt.trim()" class="line-clamp-4 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ prompt }}</p>
<p v-else class="text-sm text-zinc-600">{{ promptPlaceholder }}</p>
<span class="mt-2 inline-block text-xs text-amber-200">{{ prompt.trim() ? 'View / edit prompt' : 'Write prompt' }}</span>
</button>
</div>
<label v-if="studioMode === 'edit'" class="block min-w-0 text-sm">
<span class="mb-2 block font-medium text-zinc-300">Negative prompt</span>
<textarea
v-model="editNegative"
rows="2"
class="min-h-[4.5rem] w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:h-[calc(100%-1.75rem)] md:min-h-[8.5rem]"
placeholder="What to avoid…"
/>
</label>
</div>
<p v-if="shotScriptMode && studioMode === 'video' && parsedShots.length" class="mt-2 text-xs text-zinc-500 md:hidden">
{{ parsedShots.length }} shot{{ parsedShots.length === 1 ? '' : 's' }} in this script. Open the prompt to scroll through them.
</p>
@@ -601,16 +617,50 @@
@remove="deleteGenerationPreset"
/>
</div>
<div v-else class="max-w-md space-y-2">
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" compact />
<GenerationPresetBar
:presets="imageGenerationPresets"
v-model:selected-id="imagePresetId"
v-model:name="imagePresetName"
@load="loadGenerationPreset"
@save="saveGenerationPreset"
@remove="deleteGenerationPreset"
/>
<div v-else class="space-y-3">
<p class="text-xs text-zinc-500">Flux.2 Klein 9B. Shares the desktop GPU with video.</p>
<div class="max-w-md space-y-2">
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" compact />
<GenerationPresetBar
:presets="imageGenerationPresets"
v-model:selected-id="imagePresetId"
v-model:name="imagePresetName"
@load="loadGenerationPreset"
@save="saveGenerationPreset"
@remove="deleteGenerationPreset"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Steps</span>
<input
v-model.number="editSteps"
type="number"
:min="IMAGE_STEPS_MIN"
:max="IMAGE_STEPS_MAX"
step="1"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
@change="onEditStepsNumber"
>
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">CFG</span>
<input
v-model.number="editCfg"
type="number"
:min="IMAGE_CFG_MIN"
:max="IMAGE_CFG_MAX"
:step="IMAGE_CFG_STEP"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
@change="onEditCfgNumber"
>
</label>
<label class="col-span-2 block text-sm">
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
</label>
</div>
<p class="text-xs text-zinc-500">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.</p>
</div>
<details v-if="studioMode === 'video'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
@@ -660,11 +710,11 @@
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
class="w-20 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
class="w-24 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 focus:ring-2"
@change="onCfgNumber"
>
</div>
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}</p>
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}. Type 0–20 (0.1 steps); the box is the source of truth.</p>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
@@ -688,7 +738,6 @@
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
</label>
<LoraSelect v-model="videoLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="None" :clip-strength="false" />
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Sampler & scheduler</p>
<div class="grid grid-cols-2 gap-3">
@@ -713,35 +762,6 @@
</div>
</details>
<details v-else class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Image edit settings</summary>
<div class="mt-4 space-y-4">
<p class="text-xs text-zinc-500">
Flux.2 Klein 9B. Shares the desktop GPU with video.
</p>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Negative prompt</span>
<textarea v-model="editNegative" rows="2" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Steps · {{ editSteps }}</span>
<input v-model.number="editSteps" type="range" min="4" max="40" step="1" class="w-full">
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">CFG · {{ editCfg }}</span>
<input v-model.number="editCfg" type="range" min="0.5" max="4" step="0.1" class="w-full">
</label>
<label class="block text-sm sm:col-span-2">
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
</label>
</div>
<LoraSelect v-model="imageLoraStack" :names="imageLoraOptions" label="LoRA" empty-label="None" />
<p class="text-xs text-zinc-500">Graph default is 20 steps, CFG 1, euler. A second still switches the two-image branch on.</p>
</div>
</details>
<label
v-if="studioMode === 'video' && queuedExtensionCount > 0"
class="flex cursor-pointer items-start gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3"
@@ -1605,7 +1625,20 @@ import {
readLoraStack,
type LoraStackItem
} from '~/utils/loras'
import type { GenerationPreset } from '~/utils/generationPresets'
import {
clampImageCfg,
clampImageSteps,
clampVideoCfg,
IMAGE_CFG_MAX,
IMAGE_CFG_MIN,
IMAGE_CFG_STEP,
IMAGE_STEPS_MAX,
IMAGE_STEPS_MIN,
VIDEO_CFG_MAX,
VIDEO_CFG_MIN,
VIDEO_CFG_STEP,
type GenerationPreset
} from '~/utils/generationPresets'
import {
coerceVideoWorkflow,
composeVideoWorkflow,
@@ -1800,9 +1833,9 @@ const videoPresetId = ref('')
const imagePresetId = ref('')
const videoPresetName = ref('')
const imagePresetName = ref('')
const CFG_MIN = 1
const CFG_MAX = 10
const CFG_STEP = 0.5
const CFG_MIN = VIDEO_CFG_MIN
const CFG_MAX = VIDEO_CFG_MAX
const CFG_STEP = VIDEO_CFG_STEP
const CFG_TURBO = 1.5
const CFG_QUALITY = 4
const cfg = ref(CFG_TURBO)
@@ -2847,8 +2880,15 @@ function clampDuration(value: number) {
}
function clampCfg(value: number) {
if (!Number.isFinite(value)) return turbo.value ? CFG_TURBO : CFG_QUALITY
return Math.min(CFG_MAX, Math.max(CFG_MIN, Math.round(value / CFG_STEP) * CFG_STEP))
return clampVideoCfg(value, turbo.value ? CFG_TURBO : CFG_QUALITY)
}
function onEditStepsNumber() {
editSteps.value = clampImageSteps(editSteps.value)
}
function onEditCfgNumber() {
editCfg.value = clampImageCfg(editCfg.value)
}
function onCfgSlider(event: Event) {
@@ -2925,8 +2965,8 @@ function currentPresetSnapshot() {
kind: 'image' as const,
loraStack: filterLoraStackForStudio(imageLoraStack.value, ltxEnabled.value),
settings: {
steps: editSteps.value,
cfg: editCfg.value,
steps: clampImageSteps(editSteps.value),
cfg: clampImageCfg(editCfg.value),
negative: editNegative.value
}
}
@@ -2954,8 +2994,8 @@ function applyGenerationPreset(preset: GenerationPreset) {
if (preset.kind === 'image') {
imageLoraStack.value = stack
const settings = preset.settings as { steps?: number; cfg?: number; negative?: string }
if (typeof settings.steps === 'number') editSteps.value = settings.steps
if (typeof settings.cfg === 'number') editCfg.value = settings.cfg
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
imagePresetId.value = preset.id
imagePresetName.value = preset.name
@@ -4603,8 +4643,8 @@ async function editImage() {
if (editRefFile.value) body.append('image2', editRefFile.value)
body.append('prompt', prompt.value.trim())
body.append('negative', editNegative.value)
body.append('steps', String(editSteps.value))
body.append('cfg', String(editCfg.value))
body.append('steps', String(clampImageSteps(editSteps.value)))
body.append('cfg', String(clampImageCfg(editCfg.value)))
body.append('seed', seedInput.value || 'random')
body.append('folderId', folderId.value)
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
+3 -14
View File
@@ -2,18 +2,7 @@ import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/st
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageDimensions } from '~/server/utils/resolution'
function parseSteps(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return 20
return Math.min(40, Math.max(1, Math.round(value)))
}
function parseCfg(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return 1
return Math.min(8, Math.max(0.5, Math.round(value * 10) / 10))
}
import { clampImageCfg, clampImageSteps } from '~/utils/generationPresets'
function parsePasses(raw: string | undefined) {
if (!raw) return [] as { prompt: string }[]
@@ -75,8 +64,8 @@ export default defineEventHandler(async (event) => {
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
const hideInput = fields.hideInput === 'true'
const hideThumbnail = fields.hideThumbnail === 'true'
const steps = parseSteps(fields.steps)
const cfg = parseCfg(fields.cfg)
const steps = clampImageSteps(fields.steps)
const cfg = clampImageCfg(fields.cfg)
const seed = fields.seed && fields.seed !== 'random'
? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647)
+2 -5
View File
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import { composeShotPrompt } from '~/utils/globalLocks'
import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/server/utils/loras'
import { readLoraStack } from '~/utils/loras'
import { clampVideoCfg } from '~/utils/generationPresets'
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
@@ -24,11 +25,7 @@ function parseFps(raw: unknown) {
}
function parseCfg(raw: unknown, turbo: boolean) {
const fallback = turbo ? 1.5 : 4
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
const snapped = Math.round(value * 2) / 2
return Math.min(10, Math.max(1, snapped))
return clampVideoCfg(raw, turbo ? 1.5 : 4)
}
function parseSampler(raw: unknown) {
+2 -4
View File
@@ -1,6 +1,7 @@
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
import { clampVideoCfg } from '~/utils/generationPresets'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
@@ -66,10 +67,7 @@ function parseFps(raw: string | undefined) {
function parseCfg(raw: string | undefined, turbo: boolean, ltx = false) {
const fallback = ltx ? 3.5 : (turbo ? 1.5 : 4)
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
const snapped = Math.round(value * 2) / 2
return Math.min(10, Math.max(1, snapped))
return clampVideoCfg(raw, fallback)
}
function parseSampler(raw: string | undefined, ltx = false) {
+6 -9
View File
@@ -3,6 +3,9 @@ import { join } from 'node:path'
import { ownerRoot } from '~/server/utils/library'
import { filterLoraStackForStudio, normalizeLoraStack } from '~/utils/loras'
import {
clampImageCfg,
clampImageSteps,
clampVideoCfg,
normalizePresetName,
parseGenerationPresetKind,
type GenerationPreset,
@@ -54,12 +57,6 @@ function mutate<T>(owner: string, fn: (presets: GenerationPreset[]) => T): Promi
return run
}
function clampInt(raw: unknown, fallback: number, min: number, max: number) {
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
return Math.min(max, Math.max(min, Math.round(value)))
}
function clampNum(raw: unknown, fallback: number, min: number, max: number, step = 0.1) {
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
@@ -77,7 +74,7 @@ function sanitizeVideoSettings(raw: unknown): VideoPresetSettings {
return {
workflow,
turbo,
cfg: clampNum(rec.cfg, turbo ? 1.5 : 4, 1, 10, 0.5),
cfg: clampVideoCfg(rec.cfg, turbo ? 1.5 : 4),
fps: rec.fps === 12 || rec.fps === 30 || rec.fps === 24 ? Number(rec.fps) : 24,
samplerName: sampler === 'euler' || sampler === 'dpmpp_2m' || sampler === 'res_multistep' ? sampler : 'res_multistep',
scheduler: scheduler === 'ddim_uniform' || scheduler === 'sgm_uniform' || scheduler === 'simple' ? scheduler : 'simple',
@@ -90,8 +87,8 @@ function sanitizeVideoSettings(raw: unknown): VideoPresetSettings {
function sanitizeImageSettings(raw: unknown): ImagePresetSettings {
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
return {
steps: clampInt(rec.steps, 20, 1, 40),
cfg: clampNum(rec.cfg, 1, 0.5, 4, 0.1),
steps: clampImageSteps(rec.steps),
cfg: clampImageCfg(rec.cfg),
negative: String(rec.negative || '').slice(0, 2000)
}
}
+30
View File
@@ -21,6 +21,36 @@ export type ImagePresetSettings = {
negative?: string
}
export const IMAGE_STEPS_MIN = 1
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 VIDEO_CFG_MIN = 0
export const VIDEO_CFG_MAX = 20
export const VIDEO_CFG_STEP = 0.1
export function clampImageSteps(raw: unknown, fallback = 20) {
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
return Math.min(IMAGE_STEPS_MAX, Math.max(IMAGE_STEPS_MIN, Math.round(value)))
}
export function clampImageCfg(raw: unknown, fallback = 1) {
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
const snapped = Math.round(value / IMAGE_CFG_STEP) * IMAGE_CFG_STEP
return Math.min(IMAGE_CFG_MAX, Math.max(IMAGE_CFG_MIN, Math.round(snapped * 10) / 10))
}
export function clampVideoCfg(raw: unknown, fallback = 1.5) {
const value = Number(raw)
if (!Number.isFinite(value)) return fallback
const snapped = Math.round(value / VIDEO_CFG_STEP) * VIDEO_CFG_STEP
return Math.min(VIDEO_CFG_MAX, Math.max(VIDEO_CFG_MIN, Math.round(snapped * 10) / 10))
}
export type GenerationPreset = {
id: string
name: string