Overhaul Studio controls and add image batch from one still.

Video and Image pickers, thumbwheels, aspect icons, and a Scale to MP paddle; extra prompts default to batch so each run starts from the original image.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 23:16:19 -05:00
co-authored by Cursor
parent 8df265cbf3
commit 755aaeede8
17 changed files with 791 additions and 233 deletions
+42
View File
@@ -0,0 +1,42 @@
<template>
<div>
<p v-if="heading" class="mb-2 text-xs uppercase tracking-wider text-zinc-500">{{ heading }}</p>
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in options"
:key="option.id"
type="button"
class="rounded-xl border px-3 py-2.5 text-left text-sm transition"
:class="model === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-200 hover:border-white/20'"
@click="model = option.id"
>
<span class="mb-1.5 flex h-8 items-center" aria-hidden="true">
<svg v-if="option.id === 'auto'" class="h-7 w-10" viewBox="0 0 40 28" fill="none">
<rect x="11" y="8" width="18" height="12" rx="1.5" stroke="currentColor" stroke-width="1.75" stroke-dasharray="2.5 2" />
<path d="M5 9V4h5M35 9V4h-5M5 19v5h5M35 19v5h-5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else-if="option.id === '16:9'" class="h-7 w-10" viewBox="0 0 40 28" fill="none">
<rect x="2" y="8" width="36" height="13" rx="2.5" stroke="currentColor" stroke-width="1.75" />
</svg>
<svg v-else-if="option.id === '9:16'" class="h-7 w-10" viewBox="0 0 40 28" fill="none">
<rect x="14" y="2" width="12" height="24" rx="2.5" stroke="currentColor" stroke-width="1.75" />
</svg>
<svg v-else class="h-7 w-10" viewBox="0 0 40 28" fill="none">
<rect x="9" y="3" width="22" height="22" rx="2.5" stroke="currentColor" stroke-width="1.75" />
</svg>
</span>
<span class="block font-semibold leading-tight">{{ option.label }}</span>
<span class="mt-0.5 block text-[11px] leading-snug text-zinc-400">{{ option.hint }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
heading?: string
options: { id: string; label: string; hint: string }[]
}>()
const model = defineModel<string>({ required: true })
</script>
+9 -11
View File
@@ -17,17 +17,16 @@
> >
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-zinc-500">s</span> <span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-zinc-500">s</span>
</div> </div>
<input <div class="mt-3">
class="aigen-slider mt-3 w-full" <Thumbwheel
type="range" :model-value="slider"
:min="SLIDER_MIN" :min="SLIDER_MIN"
:max="SLIDER_MAX" :max="SLIDER_MAX"
:step="0.1" :step="0.1"
:value="slider" compact
:style="{ background: `linear-gradient(to right, #fbbf24 ${sliderPct}%, #27272a ${sliderPct}%)` }" @update:model-value="onSliderValue"
:aria-label="label || 'Duration in seconds'" />
@input="onSlider" </div>
>
<p v-if="hint" class="mt-1 text-[11px] text-zinc-500">{{ hint }}</p> <p v-if="hint" class="mt-1 text-[11px] text-zinc-500">{{ hint }}</p>
</div> </div>
</template> </template>
@@ -51,7 +50,6 @@ const draft = ref(formatDuration(model.value))
const scrubbing = ref(false) const scrubbing = ref(false)
const slider = computed(() => Math.min(SLIDER_MAX, Math.max(SLIDER_MIN, model.value))) const slider = computed(() => Math.min(SLIDER_MAX, Math.max(SLIDER_MIN, model.value)))
const sliderPct = computed(() => ((slider.value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100)
watch(model, (value) => { watch(model, (value) => {
if (!scrubbing.value) draft.value = formatDuration(value) if (!scrubbing.value) draft.value = formatDuration(value)
@@ -72,8 +70,8 @@ function commit() {
draft.value = formatDuration(model.value) draft.value = formatDuration(model.value)
} }
function onSlider(event: Event) { function onSliderValue(value: number) {
model.value = clampDuration(Number((event.target as HTMLInputElement).value)) model.value = clampDuration(value)
draft.value = formatDuration(model.value) draft.value = formatDuration(model.value)
} }
+176
View File
@@ -0,0 +1,176 @@
<template>
<div class="w-[6.5rem] min-w-0">
<div v-if="label" class="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">{{ label }}</div>
<button
type="button"
role="switch"
class="lamp-plate relative flex h-[9.75rem] w-full flex-col items-center justify-between overflow-hidden rounded-2xl px-1.5 py-2"
:class="model ? 'is-on' : 'is-off'"
:aria-checked="model"
:aria-label="label || 'Toggle'"
@click="model = !model"
>
<span class="screw tl" aria-hidden="true" />
<span class="screw tr" aria-hidden="true" />
<span class="screw bl" aria-hidden="true" />
<span class="screw br" aria-hidden="true" />
<span class="flex flex-col items-center gap-1">
<span class="led" aria-hidden="true" />
<span class="stamp" :class="model ? 'stamp-lit' : ''">ON</span>
</span>
<span class="collar" aria-hidden="true">
<span class="collar-ring" />
<span class="paddle" :class="model ? 'paddle-on' : 'paddle-off'">
<span class="paddle-body" />
</span>
</span>
<span class="stamp" :class="model ? '' : 'stamp-lit'">OFF</span>
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
label?: string
}>()
const model = defineModel<boolean>({ required: true })
</script>
<style scoped>
.lamp-plate {
border: 1px solid rgba(255, 255, 255, 0.12);
background:
radial-gradient(120% 80% at 50% 0%, rgba(251, 191, 36, 0.08), transparent 55%),
linear-gradient(180deg, #2a2a2e 0%, #18181b 42%, #0c0c0e 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
inset 0 -10px 18px rgba(0, 0, 0, 0.45),
0 8px 18px rgba(0, 0, 0, 0.35);
}
.lamp-plate.is-on {
border-color: rgba(251, 191, 36, 0.45);
box-shadow:
inset 0 1px 0 rgba(253, 230, 138, 0.18),
inset 0 -10px 18px rgba(0, 0, 0, 0.4),
0 0 18px rgba(251, 191, 36, 0.18);
}
.screw {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background:
radial-gradient(circle at 35% 30%, #f4f4f5, #a1a1aa 42%, #52525b 70%, #27272a);
box-shadow:
0 0 0 0.5px rgba(255, 255, 255, 0.25),
inset 0 -1px 1px rgba(0, 0, 0, 0.55);
}
.screw::after {
content: '';
position: absolute;
inset: 2px;
background:
linear-gradient(#3f3f46, #3f3f46) center / 4px 1px no-repeat,
linear-gradient(#3f3f46, #3f3f46) center / 1px 4px no-repeat;
opacity: 0.85;
}
.tl { top: 6px; left: 6px; }
.tr { top: 6px; right: 6px; }
.bl { bottom: 6px; left: 6px; }
.br { bottom: 6px; right: 6px; }
.led {
width: 10px;
height: 10px;
border-radius: 50%;
background: #3f3f46;
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.85),
0 0 0 1px rgba(0, 0, 0, 0.4);
}
.is-on .led {
background: radial-gradient(circle at 35% 30%, #fef3c7, #fbbf24 55%, #d97706);
box-shadow:
0 0 10px rgba(251, 191, 36, 1),
0 0 18px rgba(251, 191, 36, 0.55),
inset 0 1px 1px rgba(255, 255, 255, 0.7);
}
.stamp {
font-size: 9px;
font-weight: 800;
letter-spacing: 0.18em;
color: rgba(244, 244, 245, 0.28);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7);
}
.stamp-lit {
color: #fef3c7;
text-shadow: 0 0 8px rgba(251, 191, 36, 0.7);
}
.collar {
position: relative;
display: grid;
place-items: center;
width: 3.35rem;
height: 3.35rem;
}
.collar-ring {
position: absolute;
inset: 0;
border-radius: 50%;
background:
radial-gradient(circle at 30% 26%, #fff 0 5%, transparent 16%),
repeating-radial-gradient(circle at 50% 50%, #e4e4e7 0 1px, #a1a1aa 2px, #71717a 3px, #3f3f46 4px);
box-shadow:
0 3px 6px rgba(0, 0, 0, 0.6),
inset 0 2px 3px rgba(255, 255, 255, 0.5),
inset 0 -4px 6px rgba(0, 0, 0, 0.6);
}
.collar-ring::after {
content: '';
position: absolute;
inset: 10px;
border-radius: 50%;
background: radial-gradient(circle at 50% 40%, #3f3f46, #09090b 72%);
box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.9);
}
.paddle {
position: relative;
z-index: 1;
width: 1.15rem;
height: 2.55rem;
transform-origin: 50% 50%;
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1);
filter: drop-shadow(0 5px 4px rgba(0, 0, 0, 0.55));
}
.paddle-on {
transform: translateY(-0.42rem) rotate(-12deg);
}
.paddle-off {
transform: translateY(0.42rem) rotate(12deg);
}
.paddle-body {
display: block;
width: 100%;
height: 100%;
border-radius: 0.7rem 0.7rem 0.45rem 0.45rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.38) 0%, rgba(255, 255, 255, 0.05) 28%, transparent 45%),
linear-gradient(90deg, #0a0a0b 0%, #3f3f46 46%, #111113 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.4),
inset 0 -3px 4px rgba(0, 0, 0, 0.7);
clip-path: polygon(22% 0, 78% 0, 100% 22%, 68% 100%, 32% 100%, 0 22%);
}
.is-on .paddle-body {
background:
linear-gradient(180deg, rgba(254, 243, 199, 0.7) 0%, rgba(251, 191, 36, 0.2) 26%, transparent 48%),
linear-gradient(90deg, #1c1408 0%, #b45309 48%, #111 100%);
box-shadow:
0 0 10px rgba(251, 191, 36, 0.45),
inset 0 1px 0 rgba(254, 243, 199, 0.55),
inset 0 -3px 4px rgba(0, 0, 0, 0.7);
}
</style>
+16 -29
View File
@@ -42,39 +42,27 @@
Remove Remove
</button> </button>
</div> </div>
<div class="mt-1.5 grid grid-cols-2 gap-2"> <div class="mt-1.5 flex flex-wrap gap-3">
<label class="block min-w-0"> <Thumbwheel
<span class="mb-0.5 flex justify-between text-[10px] uppercase tracking-wider text-zinc-500"> :model-value="item.strengthModel"
<span>Model</span> label="Model"
<span class="tabular-nums normal-case text-zinc-400">{{ formatLoraStrength(item.strengthModel) }}</span>
</span>
<input
type="range"
class="aigen-slider w-full"
:min="LORA_STRENGTH_MIN" :min="LORA_STRENGTH_MIN"
:max="LORA_STRENGTH_MAX" :max="LORA_STRENGTH_MAX"
:step="LORA_STRENGTH_STEP" :step="LORA_STRENGTH_STEP"
:value="item.strengthModel" compact
:disabled="disabled" :disabled="disabled"
@input="setStrength(index, 'strengthModel', $event)" @update:model-value="setStrength(index, 'strengthModel', $event)"
> />
</label> <Thumbwheel
<label class="block min-w-0" :class="{ 'opacity-50': !clipStrength }"> :model-value="item.strengthClip"
<span class="mb-0.5 flex justify-between text-[10px] uppercase tracking-wider text-zinc-500"> label="CLIP"
<span>CLIP</span>
<span class="tabular-nums normal-case text-zinc-400">{{ formatLoraStrength(item.strengthClip) }}</span>
</span>
<input
type="range"
class="aigen-slider w-full"
:min="LORA_STRENGTH_MIN" :min="LORA_STRENGTH_MIN"
:max="LORA_STRENGTH_MAX" :max="LORA_STRENGTH_MAX"
:step="LORA_STRENGTH_STEP" :step="LORA_STRENGTH_STEP"
:value="item.strengthClip" compact
:disabled="disabled || !clipStrength" :disabled="disabled || !clipStrength"
@input="setStrength(index, 'strengthClip', $event)" @update:model-value="setStrength(index, 'strengthClip', $event)"
> />
</label>
</div> </div>
</li> </li>
</ul> </ul>
@@ -107,7 +95,6 @@ import {
LORA_STRENGTH_MIN, LORA_STRENGTH_MIN,
LORA_STRENGTH_STEP, LORA_STRENGTH_STEP,
clampLoraStrength, clampLoraStrength,
formatLoraStrength,
loraDisplayName, loraDisplayName,
loraIdentityKey, loraIdentityKey,
makeLoraStackItem, makeLoraStackItem,
@@ -208,8 +195,8 @@ function onDragEnd() {
dragOverIndex.value = null dragOverIndex.value = null
} }
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', event: Event) { function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
const value = clampLoraStrength((event.target as HTMLInputElement).value) const next = clampLoraStrength(value)
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: value } : item) model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
} }
</script> </script>
+88
View File
@@ -0,0 +1,88 @@
<template>
<div class="segment-readout flex h-11 items-center justify-center gap-px px-1.5" aria-hidden="true">
<template v-for="(ch, i) in chars" :key="i">
<span v-if="ch === '.'" class="dot on" />
<span v-else class="digit" :data-n="ch">
<i class="seg a" :class="{ on: lit(ch, 'a') }" />
<i class="seg b" :class="{ on: lit(ch, 'b') }" />
<i class="seg c" :class="{ on: lit(ch, 'c') }" />
<i class="seg d" :class="{ on: lit(ch, 'd') }" />
<i class="seg e" :class="{ on: lit(ch, 'e') }" />
<i class="seg f" :class="{ on: lit(ch, 'f') }" />
<i class="seg g" :class="{ on: lit(ch, 'g') }" />
</span>
</template>
</div>
</template>
<script setup lang="ts">
const MAP: Record<string, string> = {
'0': 'abcdef',
'1': 'bc',
'2': 'abged',
'3': 'abcdg',
'4': 'fgbc',
'5': 'afgcd',
'6': 'afedcg',
'7': 'abc',
'8': 'abcdefg',
'9': 'abcdfg',
'-': 'g'
}
const props = defineProps<{
value: string
}>()
const chars = computed(() => {
const raw = String(props.value || '0')
return raw.split('').filter(ch => ch === '.' || MAP[ch])
})
function lit(ch: string, seg: string) {
return (MAP[ch] || '').includes(seg)
}
</script>
<style scoped>
.segment-readout {
background:
linear-gradient(180deg, rgba(9, 9, 11, 0.95), rgba(24, 24, 27, 0.9));
box-shadow:
inset 0 2px 8px rgba(0, 0, 0, 0.75),
inset 0 -1px 0 rgba(255, 255, 255, 0.04);
}
.digit {
position: relative;
width: 0.72rem;
height: 1.15rem;
}
.seg {
position: absolute;
background: rgba(251, 191, 36, 0.1);
border-radius: 1px;
}
.seg.on {
background: #fbbf24;
box-shadow: 0 0 6px rgba(251, 191, 36, 0.65);
}
.a { top: 0; left: 2px; right: 2px; height: 2px; }
.d { bottom: 0; left: 2px; right: 2px; height: 2px; }
.g { top: calc(50% - 1px); left: 2px; right: 2px; height: 2px; }
.f { top: 2px; left: 0; width: 2px; height: calc(50% - 3px); }
.b { top: 2px; right: 0; width: 2px; height: calc(50% - 3px); }
.e { bottom: 2px; left: 0; width: 2px; height: calc(50% - 3px); }
.c { bottom: 2px; right: 0; width: 2px; height: calc(50% - 3px); }
.dot {
width: 3px;
height: 3px;
margin: 0 1px 1px;
align-self: flex-end;
border-radius: 1px;
background: rgba(251, 191, 36, 0.1);
}
.dot.on {
background: #fbbf24;
box-shadow: 0 0 6px rgba(251, 191, 36, 0.65);
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<template>
<div class="w-[6.5rem] min-w-0" :class="disabled ? 'opacity-50' : ''">
<div v-if="label" class="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">{{ label }}</div>
<div class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950">
<div class="border-b border-white/5">
<SegmentDigits :value="display" />
</div>
<div
ref="root"
class="thumbwheel relative w-full select-none"
:class="[
compact ? 'h-28' : 'h-36',
disabled ? 'pointer-events-none' : 'cursor-ns-resize'
]"
role="slider"
:aria-label="ariaLabel || label || 'Value'"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="model"
:aria-disabled="disabled"
tabindex="0"
@pointerdown="onPointerDown"
@wheel.prevent="onWheel"
@keydown="onKey"
>
<div
class="thumbwheel-drum pointer-events-none absolute inset-y-1 left-1 right-8 overflow-hidden rounded-xl"
:style="{ backgroundPositionY: `${drumShift}px` }"
/>
<div class="pointer-events-none absolute inset-y-2 right-2.5 w-2.5 rounded-full bg-zinc-900 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]">
<span
class="absolute left-0 right-0 h-0.5 rounded-full bg-amber-300 shadow-[0_0_10px_rgba(251,191,36,0.85)]"
:style="{ top: `calc(${100 - pct}% - 1px)` }"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
min: number
max: number
step?: number
fineStep?: number
coarseStep?: number
label?: string
ariaLabel?: string
disabled?: boolean
compact?: boolean
digits?: number
}>(), {
step: 0.1,
fineStep: 0.1,
coarseStep: 1,
label: '',
ariaLabel: '',
disabled: false,
compact: false,
digits: 1
})
const model = defineModel<number>({ required: true })
const root = ref<HTMLElement | null>(null)
const span = computed(() => Math.max(props.max - props.min, 0.0001))
const pct = computed(() => ((clamp(model.value) - props.min) / span.value) * 100)
const display = computed(() => format(model.value))
const drumShift = computed(() => Math.round((pct.value / 100) * 72))
function clamp(value: number) {
if (!Number.isFinite(value)) return props.min
const step = props.step || 0.1
const snapped = Math.round(value / step) * step
const places = step < 0.1 ? 2 : 1
const rounded = Number(snapped.toFixed(places))
return Math.min(props.max, Math.max(props.min, rounded))
}
function format(value: number) {
const n = clamp(value)
if (props.digits === 0) return String(Math.round(n))
return n.toFixed(props.digits).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')
}
function nudge(delta: number) {
model.value = clamp(model.value + delta)
}
function onWheel(event: WheelEvent) {
if (props.disabled) return
const step = event.shiftKey ? props.coarseStep : props.fineStep
nudge(event.deltaY < 0 ? step : -step)
}
function onKey(event: KeyboardEvent) {
if (props.disabled) return
const step = event.shiftKey ? props.coarseStep : props.fineStep
if (event.key === 'ArrowUp' || event.key === 'ArrowRight') {
event.preventDefault()
nudge(step)
} else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') {
event.preventDefault()
nudge(-step)
} else if (event.key === 'Home') {
event.preventDefault()
model.value = props.min
} else if (event.key === 'End') {
event.preventDefault()
model.value = props.max
}
}
function onPointerDown(event: PointerEvent) {
if (props.disabled || event.button !== 0) return
const el = root.value
if (!el) return
event.preventDefault()
el.setPointerCapture(event.pointerId)
const height = el.clientHeight || 1
const startY = event.clientY
const startValue = model.value
const onMove = (move: PointerEvent) => {
const delta = startY - move.clientY
model.value = clamp(startValue + (delta / height) * span.value)
}
const onUp = () => {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
el.removeEventListener('pointercancel', onUp)
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
el.addEventListener('pointercancel', onUp)
}
</script>
<style scoped>
.thumbwheel {
touch-action: none;
}
.thumbwheel-drum {
background-image:
repeating-linear-gradient(
to bottom,
rgba(82, 82, 91, 0.95) 0 2px,
rgba(24, 24, 27, 1) 2px 7px,
rgba(63, 63, 70, 0.9) 7px 9px,
rgba(9, 9, 11, 1) 9px 14px
);
background-size: 100% 14px;
box-shadow:
inset 0 18px 20px rgba(0, 0, 0, 0.55),
inset 0 -18px 20px rgba(0, 0, 0, 0.55),
inset 12px 0 16px rgba(255, 255, 255, 0.04);
}
</style>
+181 -140
View File
@@ -12,30 +12,34 @@
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"> <main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5"> <section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div> <div>
<div class="mb-3 flex flex-wrap gap-2"> <div class="mb-4 grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs font-medium" class="rounded-3xl border px-4 py-4 text-left transition"
:class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'" :class="studioMode === 'video' ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200'"
@click="studioMode = 'video'; outputFocus = 'video'" @click="selectStudioKind('video')"
> >
Video <svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x="4" y="10" width="24" height="20" rx="3" stroke="currentColor" stroke-width="2" />
<path d="M28 16 36 12v16l-8-4V16Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<circle cx="12" cy="20" r="2" fill="currentColor" />
</svg>
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Video</span>
<span class="mt-0.5 block text-xs text-zinc-500">MiniMax H3 · LTX</span>
</button> </button>
<button <button
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs font-medium" class="rounded-3xl border px-4 py-4 text-left transition"
:class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'" :class="imageStudio ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200'"
@click="studioMode = 'edit'; outputFocus = 'edit'" @click="selectStudioKind('image')"
> >
Image edit <svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
</button> <rect x="6" y="8" width="28" height="24" rx="3" stroke="currentColor" stroke-width="2" />
<button <path d="m10 26 7-8 6 7 3-3 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
type="button" <circle cx="15" cy="15" r="2.5" fill="currentColor" />
class="rounded-full border px-3 py-1 text-xs font-medium" </svg>
:class="studioMode === 'editv2' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'" <span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span>
@click="studioMode = 'editv2'; outputFocus = 'edit'" <span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span>
>
Image v2
</button> </button>
</div> </div>
<h2 class="font-display text-2xl font-bold">Input</h2> <h2 class="font-display text-2xl font-bold">Input</h2>
@@ -335,13 +339,33 @@
</div> </div>
</div> </div>
<div v-if="studioMode === 'edit' || v2PassChaining" class="mt-3 space-y-3"> <div v-if="studioMode === 'edit' || v2PassChaining" class="mt-3 space-y-3">
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="imagePassMode === 'batch' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="imagePassMode = 'batch'"
>
<span class="block font-semibold">Batch</span>
<span class="text-[11px] leading-snug text-zinc-400">Each prompt from the start still</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="imagePassMode === 'chain' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="imagePassMode = 'chain'"
>
<span class="block font-semibold">Chain</span>
<span class="text-[11px] leading-snug text-zinc-400">Each pass from the last output</span>
</button>
</div>
<div <div
v-for="(item, index) in editPassQueue" v-for="(item, index) in editPassQueue"
:key="item.id" :key="item.id"
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4" class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
> >
<div class="mb-3 flex items-center justify-between gap-2"> <div class="mb-3 flex items-center justify-between gap-2">
<p class="text-sm font-medium text-zinc-200">Pass {{ index + 2 }}</p> <p class="text-sm font-medium text-zinc-200">{{ imagePassMode === 'batch' ? 'Prompt' : 'Pass' }} {{ index + 2 }}</p>
<button <button
type="button" type="button"
class="text-xs text-red-300 hover:text-red-200" class="text-xs text-red-300 hover:text-red-200"
@@ -615,7 +639,7 @@
</div> </div>
<p class="mt-2 text-xs text-zinc-500">{{ videoModelHint }}</p> <p class="mt-2 text-xs text-zinc-500">{{ videoModelHint }}</p>
</div> </div>
<div v-if="videoEngine === 'minimax' && videoStart === 'still'"> <div v-if="showLegacyMinimaxV1 && videoEngine === 'minimax' && videoStart === 'still'">
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">MiniMax graph</p> <p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">MiniMax graph</p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<button <button
@@ -751,36 +775,20 @@
<p v-if="v2Engine === 'krea'" class="mt-1 text-[11px] text-zinc-500">Flux LoRAs stay on the Flux engine. Switch back to restore that chain.</p> <p v-if="v2Engine === 'krea'" class="mt-1 text-[11px] text-zinc-500">Flux LoRAs stay on the Flux engine. Switch back to restore that chain.</p>
</div> </div>
<div> <div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p> <AspectRatioPick :model-value="aspect" heading="Aspect ratio" :options="imageAspects" @update:model-value="setImageAspect" />
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in imageAspects"
:key="option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setImageAspect(option.id)"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p> <p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
</div> </div>
<div v-if="v2Mode === 'refine' || (v2Engine === 'krea' && v2Mode !== 'generate')" class="space-y-2 rounded-2xl border border-white/10 bg-zinc-950/40 p-3"> <div v-if="v2Mode === 'refine' || (v2Engine === 'krea' && v2Mode !== 'generate')" class="space-y-2 rounded-2xl border border-white/10 bg-zinc-950/40 p-3">
<label class="block text-sm"> <label class="block text-sm">
<span class="mb-1 block text-zinc-300">Strength (denoise)</span> <span class="mb-1 block text-zinc-300">Strength (denoise)</span>
<div class="flex items-center gap-3"> <Thumbwheel
<input v-model="v2Denoise"
v-model.number="v2Denoise"
type="range"
:min="IMAGE_V2_DENOISE_MIN" :min="IMAGE_V2_DENOISE_MIN"
:max="IMAGE_V2_DENOISE_MAX" :max="IMAGE_V2_DENOISE_MAX"
:step="IMAGE_V2_DENOISE_STEP" :step="IMAGE_V2_DENOISE_STEP"
class="w-full" :digits="2"
> compact
<span class="w-12 tabular-nums text-sm text-zinc-200">{{ v2Denoise }}</span> />
</div>
</label> </label>
<div v-if="v2Mode === 'refine'" class="flex flex-wrap gap-2"> <div v-if="v2Mode === 'refine'" class="flex flex-wrap gap-2">
<button <button
@@ -809,24 +817,46 @@
</button> </button>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="flex flex-wrap items-start gap-x-4 gap-y-3">
<label class="block text-sm"> <Thumbwheel
<span class="mb-1 block text-zinc-400">Steps</span> v-model="v2Steps"
<input v-model.number="v2Steps" type="number" :min="IMAGE_STEPS_MIN" :max="IMAGE_STEPS_MAX" step="1" :disabled="v2Turbo" 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 disabled:opacity-50"> label="Steps"
</label> :min="IMAGE_STEPS_MIN"
<label class="block text-sm"> :max="IMAGE_STEPS_MAX"
<span class="mb-1 block text-zinc-400">CFG</span> :step="1"
<input v-model.number="v2Cfg" type="number" :min="IMAGE_CFG_MIN" :max="IMAGE_CFG_MAX" :step="IMAGE_CFG_STEP" :disabled="v2Turbo" 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 disabled:opacity-50"> :fine-step="1"
</label> :coarse-step="5"
<label class="block text-sm"> :digits="0"
compact
:disabled="v2Turbo"
/>
<Thumbwheel
v-model="v2Cfg"
label="CFG"
:min="IMAGE_CFG_MIN"
:max="IMAGE_CFG_MAX"
:step="IMAGE_CFG_STEP"
compact
:disabled="v2Turbo"
/>
<template v-if="v2Mode !== 'generate'">
<LampToggle :model-value="v2ScaleOn" label="Scale to MP" @update:model-value="setV2ScaleOn" />
<Thumbwheel
v-model="v2Megapixels"
aria-label="Megapixels"
:min="IMAGE_SCALE_MP_MIN"
:max="IMAGE_SCALE_MP_MAX"
:step="IMAGE_SCALE_MP_STEP"
:digits="2"
compact
:disabled="!v2ScaleOn"
/>
</template>
</div>
<label class="block max-w-xs text-sm">
<span class="mb-1 block text-zinc-400">Seed</span> <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"> <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> </label>
<label v-if="v2Mode !== 'generate'" class="block text-sm">
<span class="mb-1 block text-zinc-400">Scale to MP</span>
<input v-model.number="v2Megapixels" type="number" :min="IMAGE_SCALE_MP_MIN" :max="IMAGE_SCALE_MP_MAX" :step="IMAGE_SCALE_MP_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">
</label>
</div>
<label v-if="v2Engine !== 'krea'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400"> <label v-if="v2Engine !== 'krea'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<span class="relative inline-flex h-5 w-9 shrink-0 items-center"> <span class="relative inline-flex h-5 w-9 shrink-0 items-center">
<input v-model="v2Turbo" type="checkbox" class="peer sr-only" role="switch"> <input v-model="v2Turbo" type="checkbox" class="peer sr-only" role="switch">
@@ -847,22 +877,9 @@
</div> </div>
</div> </div>
<div v-else-if="studioMode === 'edit'" class="space-y-3"> <div v-else-if="studioMode === 'edit'" class="space-y-3">
<p class="text-xs text-zinc-500">Flux.2 Klein 9B. Shares the desktop GPU with video.</p> <p class="text-xs text-zinc-500">Legacy Klein v1 still. New Image work uses Flux / Krea above. This graph stays for older library stills.</p>
<div> <div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p> <AspectRatioPick :model-value="aspect" heading="Aspect ratio" :options="imageAspects" @update:model-value="setImageAspect" />
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in imageAspects"
:key="'v1-' + option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="setImageAspect(option.id)"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p> <p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
</div> </div>
<div class="max-w-md space-y-2"> <div class="max-w-md space-y-2">
@@ -932,20 +949,7 @@
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary> <summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<div> <div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio / resolution</p> <AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" />
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button
v-for="option in aspects"
:key="option.id"
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm transition"
:class="aspect === option.id ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="aspect = option.id"
>
<span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.id === 'auto' ? autoHint : option.hint }}</span>
</button>
</div>
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p> <p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p>
</div> </div>
<div> <div>
@@ -956,30 +960,15 @@
</div> </div>
</div> </div>
<div class="text-sm"> <div class="text-sm">
<span class="mb-1 block text-zinc-400">CFG / Guidance scale</span> <Thumbwheel
<div class="flex items-center gap-3"> v-model="cfg"
<input label="CFG / Guidance scale"
class="aigen-slider min-w-0 flex-1"
type="range"
:min="CFG_MIN" :min="CFG_MIN"
:max="CFG_MAX" :max="CFG_MAX"
:step="CFG_STEP" :step="CFG_STEP"
:value="cfg" @update:model-value="cfgTouched = true"
:style="{ background: `linear-gradient(to right, #fbbf24 ${cfgPct}%, #27272a ${cfgPct}%)` }" />
aria-label="CFG guidance scale" <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') }}. Hover and scroll ±0.1, Shift-scroll ±1. Drag the wheel up and down.</p>
@input="onCfgSlider"
>
<input
v-model.number="cfg"
type="number"
:min="CFG_MIN"
:max="CFG_MAX"
:step="CFG_STEP"
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') }}. Type 0–20 (0.1 steps); the box is the source of truth.</p>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<label class="text-sm"> <label class="text-sm">
@@ -996,7 +985,7 @@
label="Duration" label="Duration"
:hint="shotScriptMode :hint="shotScriptMode
? 'This length applies to the initial shot and every extension in the script.' ? 'This length applies to the initial shot and every extension in the script.'
: 'Type any length, use the slider, or drag up/down in the box.'" : 'Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s. Drag up and down.'"
/> />
</div> </div>
<label class="text-sm"> <label class="text-sm">
@@ -1326,7 +1315,7 @@
<DurationField <DurationField
v-model="extendDuration" v-model="extendDuration"
label="Extension duration" label="Extension duration"
hint="Type any length, use the slider, or drag up/down in the box." hint="Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s."
/> />
<LoraSelect v-model="extendLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="Same as clip / none" compact :clip-strength="false" /> <LoraSelect v-model="extendLoraStack" :names="videoLoraOptions" label="LoRA" empty-label="Same as clip / none" compact :clip-strength="false" />
<button <button
@@ -2007,11 +1996,13 @@ import {
IMAGE_CFG_MAX, IMAGE_CFG_MAX,
IMAGE_CFG_MIN, IMAGE_CFG_MIN,
IMAGE_CFG_STEP, IMAGE_CFG_STEP,
IMAGE_SCALE_MP_DEFAULT,
IMAGE_SCALE_MP_MAX, IMAGE_SCALE_MP_MAX,
IMAGE_SCALE_MP_MIN, IMAGE_SCALE_MP_MIN,
IMAGE_SCALE_MP_STEP, IMAGE_SCALE_MP_STEP,
IMAGE_STEPS_MAX, IMAGE_STEPS_MAX,
IMAGE_STEPS_MIN, IMAGE_STEPS_MIN,
type ImagePassMode,
VIDEO_CFG_MAX, VIDEO_CFG_MAX,
VIDEO_CFG_MIN, VIDEO_CFG_MIN,
VIDEO_CFG_STEP, VIDEO_CFG_STEP,
@@ -2271,6 +2262,7 @@ const editPassesBeforeRecommend = ref<QueuedEditPass[]>([])
const shotScriptBeforeRecommend = ref(false) const shotScriptBeforeRecommend = ref(false)
const extensionQueue = ref<QueuedExtension[]>([]) const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([]) const editPassQueue = ref<QueuedEditPass[]>([])
const imagePassMode = ref<ImagePassMode>('batch')
const clipName = ref('') const clipName = ref('')
const videoDownloadName = ref('output') const videoDownloadName = ref('output')
const editDownloadName = ref('edited') const editDownloadName = ref('edited')
@@ -2317,6 +2309,18 @@ const videoBusy = ref(false)
const editBusy = ref(false) const editBusy = ref(false)
const studioMode = ref('video') const studioMode = ref('video')
const outputFocus = ref<'video' | 'edit'>('video') const outputFocus = ref<'video' | 'edit'>('video')
const showLegacyMinimaxV1 = false
const imageStudio = computed(() => studioMode.value === 'edit' || studioMode.value === 'editv2')
function selectStudioKind(kind: 'video' | 'image') {
if (kind === 'video') {
studioMode.value = 'video'
outputFocus.value = 'video'
return
}
studioMode.value = 'editv2'
outputFocus.value = 'edit'
}
const imageComfyOk = ref(false) const imageComfyOk = ref(false)
const imageComfyBusy = ref(false) const imageComfyBusy = ref(false)
const imageComfyConfigured = ref(false) const imageComfyConfigured = ref(false)
@@ -2367,7 +2371,8 @@ const v2Task = ref<ImageV2Task>('scene')
const v2Negative = ref('') const v2Negative = ref('')
const v2Steps = ref(IMAGE_V2_STEPS_DEFAULT) const v2Steps = ref(IMAGE_V2_STEPS_DEFAULT)
const v2Cfg = ref(IMAGE_V2_CFG_DEFAULT) const v2Cfg = ref(IMAGE_V2_CFG_DEFAULT)
const v2Megapixels = ref(1) const v2Megapixels = ref(IMAGE_SCALE_MP_DEFAULT)
const v2ScaleOn = ref(true)
const v2Turbo = ref(false) const v2Turbo = ref(false)
const v2Width = ref(IMAGE_V2_GENERATE_WIDTH) const v2Width = ref(IMAGE_V2_GENERATE_WIDTH)
const v2Height = ref(IMAGE_V2_GENERATE_HEIGHT) const v2Height = ref(IMAGE_V2_GENERATE_HEIGHT)
@@ -2501,7 +2506,7 @@ const pendingLibraryDelete = ref<PendingLibraryDelete | null>(null)
const libraryDeleteBusy = ref(false) const libraryDeleteBusy = ref(false)
const videoEngine = ref<VideoEngineId>('minimax') const videoEngine = ref<VideoEngineId>('minimax')
const videoStart = ref<VideoStartId>('still') const videoStart = ref<VideoStartId>('still')
const minimaxGraph = ref<MiniMaxGraphId>('v1') const minimaxGraph = ref<MiniMaxGraphId>('v2')
const videoWorkflow = computed(() => composeVideoWorkflow(videoEngine.value, videoStart.value, minimaxGraph.value)) const videoWorkflow = computed(() => composeVideoWorkflow(videoEngine.value, videoStart.value, minimaxGraph.value))
const textToVideo = computed(() => isTextToVideo(videoWorkflow.value)) const textToVideo = computed(() => isTextToVideo(videoWorkflow.value))
const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value)) const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
@@ -2718,6 +2723,10 @@ const autoHint = computed(() => {
const size = autoResolution(imageWidth.value, imageHeight.value) const size = autoResolution(imageWidth.value, imageHeight.value)
return `${size.width} × ${size.height} · from still` return `${size.width} × ${size.height} · from still`
}) })
const videoAspects = computed(() => aspects.map(option => ({
...option,
hint: option.id === 'auto' ? autoHint.value : option.hint
})))
const videoModelHint = computed(() => { const videoModelHint = computed(() => {
if (ltxVideo.value) { if (ltxVideo.value) {
return textToVideo.value return textToVideo.value
@@ -2819,29 +2828,37 @@ const editV2BlockReason = computed(() => {
}) })
const v2PassChaining = computed(() => studioMode.value === 'editv2' && (v2Mode.value === 'edit' || v2Mode.value === 'compose')) const v2PassChaining = computed(() => studioMode.value === 'editv2' && (v2Mode.value === 'edit' || v2Mode.value === 'compose'))
const v2ModeLocked = computed(() => v2PassChaining.value && editPassQueue.value.length > 0) const v2ModeLocked = computed(() => v2PassChaining.value && editPassQueue.value.length > 0)
const extraPassButtonLabel = computed(() => ( const extraPassButtonLabel = computed(() => {
studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass' if (imagePassMode.value === 'batch') return '+ Prompt'
)) return studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass'
})
const extraPassHint = computed(() => { const extraPassHint = computed(() => {
if (imagePassMode.value === 'batch') {
return 'Every prompt runs from the start still. Outputs do not feed the next prompt.'
}
if (studioMode.value === 'editv2' && v2Mode.value === 'compose') { if (studioMode.value === 'editv2' && v2Mode.value === 'compose') {
return 'Each extra pass is another Compose on the last output, with the same still B. You cannot switch graphs between passes.' return 'Each extra pass is another Compose on the last output, with the same still B. You cannot switch graphs between passes.'
} }
return 'Each extra pass is another Edit on the last output. You cannot switch graphs between passes.' return 'Each extra pass is another Edit on the last output. You cannot switch graphs between passes.'
}) })
const extraPassPlaceholder = computed(() => ( const extraPassPlaceholder = computed(() => {
studioMode.value === 'editv2' && v2Mode.value === 'compose' if (imagePassMode.value === 'batch') return 'Another prompt for the same start still…'
return studioMode.value === 'editv2' && v2Mode.value === 'compose'
? 'Next compose prompt, applied to the previous pass output…' ? 'Next compose prompt, applied to the previous pass output…'
: 'Next edit prompt, applied to the previous pass output…' : 'Next edit prompt, applied to the previous pass output…'
)) })
const editV2SubmitLabel = computed(() => { const editV2SubmitLabel = computed(() => {
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running') const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
const n = v2PassChaining.value ? editPassQueue.value.length : 0 const n = v2PassChaining.value ? editPassQueue.value.length : 0
const label = v2Mode.value === 'generate' ? 'Generate image' : v2Mode.value === 'refine' ? 'Refine image' : v2Mode.value === 'compose' ? 'Compose image' : 'Edit image' const label = v2Mode.value === 'generate' ? 'Generate image' : v2Mode.value === 'refine' ? 'Refine image' : v2Mode.value === 'compose' ? 'Compose image' : 'Edit image'
if (!occupied) { if (!occupied) {
if (!n) return label if (!n) return label
if (imagePassMode.value === 'batch') return n === 1 ? `${label} × 2` : `${label} × ${n + 1}`
return n === 1 ? `${label} + 1 pass` : `${label} + ${n} passes` return n === 1 ? `${label} + 1 pass` : `${label} + ${n} passes`
} }
if (n) return `Queue ${n + 1} passes` if (n) {
return imagePassMode.value === 'batch' ? `Queue ${n + 1} from start still` : `Queue ${n + 1} passes`
}
return `Queue ${label.toLowerCase()}` return `Queue ${label.toLowerCase()}`
}) })
const generateLabel = computed(() => { const generateLabel = computed(() => {
@@ -2865,13 +2882,15 @@ const queuedExtensionCount = computed(() => (
const editLabel = computed(() => { const editLabel = computed(() => {
const n = editPassQueue.value.length const n = editPassQueue.value.length
if (!n) return 'Edit image' if (!n) return 'Edit image'
if (imagePassMode.value === 'batch') return n === 1 ? 'Edit image × 2' : `Edit image × ${n + 1}`
return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes` return n === 1 ? 'Edit image + 1 pass' : `Edit image + ${n} passes`
}) })
const editSubmitLabel = computed(() => { const editSubmitLabel = computed(() => {
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running') const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
if (!occupied) return editLabel.value if (!occupied) return editLabel.value
const n = editPassQueue.value.length const n = editPassQueue.value.length
return n ? `Queue ${n + 1} passes` : 'Queue edit' if (!n) return 'Queue edit'
return imagePassMode.value === 'batch' ? `Queue ${n + 1} from start still` : `Queue ${n + 1} passes`
}) })
const parsedShots = computed(() => parseShotScript(prompt.value)) const parsedShots = computed(() => parseShotScript(prompt.value))
const recommendedShots = computed(() => { const recommendedShots = computed(() => {
@@ -3468,12 +3487,12 @@ onMounted(async () => {
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true' autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
videoEngine.value = 'minimax' videoEngine.value = 'minimax'
videoStart.value = 'still' videoStart.value = 'still'
minimaxGraph.value = 'v1' minimaxGraph.value = 'v2'
try { try {
const saved = parseVideoWorkflow(localStorage.getItem('aigen-video-workflow') || '') const saved = parseVideoWorkflow(localStorage.getItem('aigen-video-workflow') || '')
videoEngine.value = videoEngineOf(saved) videoEngine.value = videoEngineOf(saved)
videoStart.value = videoStartOf(saved) videoStart.value = videoStartOf(saved)
minimaxGraph.value = minimaxGraphOf(saved) minimaxGraph.value = minimaxGraphOf(saved) === 'v1' ? 'v2' : minimaxGraphOf(saved)
const engine = localStorage.getItem('aigen-video-engine') const engine = localStorage.getItem('aigen-video-engine')
const start = localStorage.getItem('aigen-video-start') const start = localStorage.getItem('aigen-video-start')
if (engine === 'ltx' || engine === 'minimax') videoEngine.value = engine if (engine === 'ltx' || engine === 'minimax') videoEngine.value = engine
@@ -3686,6 +3705,11 @@ function onEditScaleMegapixels() {
editScaleMegapixels.value = clampImageScaleMegapixels(editScaleMegapixels.value) editScaleMegapixels.value = clampImageScaleMegapixels(editScaleMegapixels.value)
} }
function setV2ScaleOn(on: boolean) {
v2ScaleOn.value = on
if (on) v2Megapixels.value = IMAGE_SCALE_MP_DEFAULT
}
function onCfgSlider(event: Event) { function onCfgSlider(event: Event) {
cfgTouched.value = true cfgTouched.value = true
cfg.value = clampCfg(Number((event.target as HTMLInputElement).value)) cfg.value = clampCfg(Number((event.target as HTMLInputElement).value))
@@ -3919,7 +3943,8 @@ function currentPresetSnapshot() {
consistencyClip: imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0, consistencyClip: imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0,
steps: clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT), steps: clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT),
cfg: clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT), cfg: clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT),
megapixels: v2Mode.value === 'generate' ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1), megapixels: v2Mode.value === 'generate' || !v2ScaleOn.value ? undefined : clampImageScaleMegapixels(v2Megapixels.value, 1),
scaleToTotalPixels: v2Mode.value === 'generate' ? undefined : v2ScaleOn.value,
turbo: v2Turbo.value === true, turbo: v2Turbo.value === true,
strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.value), strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.value),
aspect: aspect.value, aspect: aspect.value,
@@ -3985,6 +4010,7 @@ function applyGenerationPreset(preset: GenerationPreset) {
v2Steps.value = clampImageSteps(settings.steps, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT) v2Steps.value = clampImageSteps(settings.steps, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)
v2Cfg.value = clampImageCfg(settings.cfg, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT) v2Cfg.value = clampImageCfg(settings.cfg, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)
v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
v2ScaleOn.value = v2Mode.value !== 'generate' && settings.scaleToTotalPixels !== false
v2Turbo.value = settings.turbo === true v2Turbo.value = settings.turbo === true
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength) if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength)
aspect.value = parseImageAspect(settings.aspect) aspect.value = parseImageAspect(settings.aspect)
@@ -5277,6 +5303,7 @@ function applyStillSettings(still: LibraryStill) {
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)
v2ScaleOn.value = mode !== 'generate' && settings.scaleToTotalPixels !== false
v2Turbo.value = settings.turbo === true v2Turbo.value = settings.turbo === true
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength) if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength)
aspect.value = parseImageAspect(settings.aspect) aspect.value = parseImageAspect(settings.aspect)
@@ -5798,6 +5825,19 @@ function removeEditPass(id: string) {
editPassQueue.value = editPassQueue.value.filter(item => item.id !== id) editPassQueue.value = editPassQueue.value.filter(item => item.id !== id)
} }
function imagePassUnit() {
return imagePassMode.value === 'batch' ? 'Batch' : 'Pass'
}
function imagePassPlan(firstPrompt: string, extra: { prompt: string }[], fallback: string) {
const unit = imagePassUnit()
if (!extra.length) return [{ label: fallback, prompt: firstPrompt }]
return [
{ label: `${unit} 1`, prompt: firstPrompt },
...extra.map((item, index) => ({ label: `${unit} ${index + 2}`, prompt: item.prompt }))
]
}
async function editImage() { async function editImage() {
if (!file.value || (!prompt.value.trim() && !imageFillWithoutPrompt.value)) { if (!file.value || (!prompt.value.trim() && !imageFillWithoutPrompt.value)) {
toast(editBlockReason.value || 'Load a still first.') toast(editBlockReason.value || 'Load a still first.')
@@ -5831,6 +5871,7 @@ async function editImage() {
body.append('name', clipName.value.trim()) body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value) if (inputStillId.value) body.append('stillId', inputStillId.value)
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses)) 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)) if (imageLoraStack.value.length) body.append('loraStack', JSON.stringify(imageLoraStack.value))
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value) const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const started = await $fetch<{ const started = await $fetch<{
@@ -5847,7 +5888,9 @@ async function editImage() {
if (started.queued) { if (started.queued) {
const n = 1 + extraPasses.length const n = 1 + extraPasses.length
toast(n > 1 toast(n > 1
? `Queued ${n} edit passes behind the current job.` ? (imagePassMode.value === 'batch'
? `Queued ${n} edits from the start still behind the current job.`
: `Queued ${n} edit passes behind the current job.`)
: 'Queued behind the current job.') : 'Queued behind the current job.')
await refreshStudioQueue() await refreshStudioQueue()
return return
@@ -5865,13 +5908,10 @@ async function editImage() {
editMaxStep.value = started.steps editMaxStep.value = started.steps
editChainStep.value = 1 editChainStep.value = 1
editChainTotal.value = 1 + extraPasses.length editChainTotal.value = 1 + extraPasses.length
editChainLabel.value = extraPasses.length ? 'Pass 1' : '' editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
editOverallProgress.value = 0 editOverallProgress.value = 0
editCompletedChainStep.value = 0 editCompletedChainStep.value = 0
editActiveChainPlan.value = [ editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, 'Pass 1')
{ label: 'Pass 1', prompt: prompt.value.trim() },
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
]
startTimer('edit') startTimer('edit')
editDownloadName.value = downloadName editDownloadName.value = downloadName
editJobId.value = started.jobId editJobId.value = started.jobId
@@ -5936,9 +5976,9 @@ function setV2Engine(engine: ImageV2Engine) {
} }
} }
function setImageAspect(id: AspectId) { function setImageAspect(id: string) {
aspect.value = id aspect.value = normalizeAspect(id)
const size = generateSizeForAspect(id) const size = generateSizeForAspect(aspect.value)
v2Width.value = size.width v2Width.value = size.width
v2Height.value = size.height v2Height.value = size.height
} }
@@ -6060,7 +6100,10 @@ async function editImageV2() {
body.append('consistency_clip', String(imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0)) body.append('consistency_clip', String(imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0))
body.append('steps', String(clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT))) body.append('steps', String(clampImageSteps(v2Steps.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_STEPS : IMAGE_V2_STEPS_DEFAULT)))
body.append('cfg', String(clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT))) body.append('cfg', String(clampImageCfg(v2Cfg.value, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)))
if (v2Mode.value !== 'generate') body.append('megapixels', String(clampImageScaleMegapixels(v2Megapixels.value, 1))) if (v2Mode.value !== 'generate') {
body.append('scaleToTotalPixels', String(v2ScaleOn.value))
if (v2ScaleOn.value) body.append('megapixels', String(clampImageScaleMegapixels(v2Megapixels.value, 1)))
}
body.append('turbo', String(v2Turbo.value === true)) body.append('turbo', String(v2Turbo.value === true))
body.append('seed', seedInput.value || 'random') body.append('seed', seedInput.value || 'random')
body.append('folderId', folderId.value) body.append('folderId', folderId.value)
@@ -6069,6 +6112,7 @@ async function editImageV2() {
body.append('name', clipName.value.trim()) body.append('name', clipName.value.trim())
if (inputStillId.value) body.append('stillId', inputStillId.value) if (inputStillId.value) body.append('stillId', inputStillId.value)
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses)) if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
body.append('passMode', imagePassMode.value)
const v2Stack = filterLoraStackForImageEngine( const v2Stack = filterLoraStackForImageEngine(
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value), filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
v2Engine.value v2Engine.value
@@ -6096,7 +6140,9 @@ async function editImageV2() {
if (started.queued) { if (started.queued) {
const n = 1 + extraPasses.length const n = 1 + extraPasses.length
toast(n > 1 toast(n > 1
? `Queued ${n} Image v2 passes behind the current job.` ? (imagePassMode.value === 'batch'
? `Queued ${n} Image v2 stills from the start image behind the current job.`
: `Queued ${n} Image v2 passes behind the current job.`)
: (v2Engine.value === 'krea' ? 'Queued Krea generate behind the current job.' : 'Queued Image v2 behind the current job.')) : (v2Engine.value === 'krea' ? 'Queued Krea generate behind the current job.' : 'Queued Image v2 behind the current job.'))
await refreshStudioQueue() await refreshStudioQueue()
return return
@@ -6114,16 +6160,11 @@ async function editImageV2() {
editMaxStep.value = started.steps editMaxStep.value = started.steps
editChainStep.value = 1 editChainStep.value = 1
editChainTotal.value = 1 + extraPasses.length editChainTotal.value = 1 + extraPasses.length
editChainLabel.value = extraPasses.length ? 'Pass 1' : '' editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
editOverallProgress.value = 0 editOverallProgress.value = 0
editCompletedChainStep.value = 0 editCompletedChainStep.value = 0
const passNoun = v2Mode.value === 'compose' ? 'Compose' : v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'generate' ? 'Generate' : 'Edit' const passNoun = v2Mode.value === 'compose' ? 'Compose' : v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'generate' ? 'Generate' : 'Edit'
editActiveChainPlan.value = extraPasses.length editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, passNoun)
? [
{ label: 'Pass 1', prompt: prompt.value.trim() },
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
]
: [{ label: passNoun, prompt: prompt.value.trim() }]
startTimer('edit') startTimer('edit')
editDownloadName.value = downloadName editDownloadName.value = downloadName
editJobId.value = started.jobId editJobId.value = started.jobId
@@ -6181,7 +6222,7 @@ function applyVideoWorkflow(raw?: string) {
const parsed = coerceVideoWorkflow(raw, ltxEnabled.value) const parsed = coerceVideoWorkflow(raw, ltxEnabled.value)
videoEngine.value = videoEngineOf(parsed) videoEngine.value = videoEngineOf(parsed)
videoStart.value = videoStartOf(parsed) videoStart.value = videoStartOf(parsed)
minimaxGraph.value = minimaxGraphOf(parsed) minimaxGraph.value = minimaxGraphOf(parsed) === 'v1' ? 'v2' : minimaxGraphOf(parsed)
} }
async function ensureVideoInputStill() { async function ensureVideoInputStill() {
+3 -1
View File
@@ -3,7 +3,7 @@ import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/serv
import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy' import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy'
import { imageDimensions } from '~/server/utils/resolution' import { imageDimensions } from '~/server/utils/resolution'
import { parseImageAspect } from '~/utils/imageAspect' import { parseImageAspect } from '~/utils/imageAspect'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImageScaleToTotalPixels } from '~/utils/generationPresets' import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImagePassMode, parseImageScaleToTotalPixels } from '~/utils/generationPresets'
function parsePasses(raw: string | undefined) { function parsePasses(raw: string | undefined) {
if (!raw) return [] as { prompt: string }[] if (!raw) return [] as { prompt: string }[]
@@ -35,6 +35,7 @@ export default defineEventHandler(async (event) => {
const prompt = (fields.prompt || '').trim() const prompt = (fields.prompt || '').trim()
const extraPasses = parsePasses(fields.passes) const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect) const aspect = parseImageAspect(fields.aspect)
if (!image) { if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' }) throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
@@ -152,6 +153,7 @@ export default defineEventHandler(async (event) => {
queueAutoRun: false, queueAutoRun: false,
negative: (fields.negative || '').trim(), negative: (fields.negative || '').trim(),
passes, passes,
passMode,
referenceStillId: savedRef?.id, referenceStillId: savedRef?.id,
referenceStillFilename: savedRef?.filename, referenceStillFilename: savedRef?.filename,
scaleToTotalPixels, scaleToTotalPixels,
+11 -3
View File
@@ -2,7 +2,7 @@ import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/
import { comfyConfigured } from '~/server/utils/comfy' import { comfyConfigured } from '~/server/utils/comfy'
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect' import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
import { imageDimensions } from '~/server/utils/resolution' import { imageDimensions } from '~/server/utils/resolution'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps } from '~/utils/generationPresets' import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImagePassMode } from '~/utils/generationPresets'
import { import {
IMAGE_V2_CFG_DEFAULT, IMAGE_V2_CFG_DEFAULT,
IMAGE_V2_CONSISTENCY_CLIP, IMAGE_V2_CONSISTENCY_CLIP,
@@ -137,6 +137,7 @@ export default defineEventHandler(async (event) => {
) )
const prompt = String(fields.prompt || '').trim() const prompt = String(fields.prompt || '').trim()
const extraPasses = parsePasses(fields.passes) const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect) const aspect = parseImageAspect(fields.aspect)
if (!prompt && (mode === 'generate' || aspect === 'auto')) { if (!prompt && (mode === 'generate' || aspect === 'auto')) {
throw createError({ statusCode: 400, statusMessage: 'A prompt is required' }) throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
@@ -213,7 +214,13 @@ export default defineEventHandler(async (event) => {
const cfg = engine === 'krea' const cfg = engine === 'krea'
? clampImageCfg(fields.cfg, IMAGE_V2_KREA_CFG) ? clampImageCfg(fields.cfg, IMAGE_V2_KREA_CFG)
: turbo ? IMAGE_V2_TURBO_CFG : clampImageCfg(fields.cfg, IMAGE_V2_CFG_DEFAULT) : turbo ? IMAGE_V2_TURBO_CFG : clampImageCfg(fields.cfg, IMAGE_V2_CFG_DEFAULT)
const megapixels = mode === 'generate' ? 0 : clampImageScaleMegapixels(fields.megapixels ?? fields.scaleMegapixels, 1) const megapixels = mode === 'generate'
? 0
: fields.scaleToTotalPixels == null
? clampImageScaleMegapixels(fields.megapixels ?? fields.scaleMegapixels, 1)
: parseBool(fields.scaleToTotalPixels)
? clampImageScaleMegapixels(fields.megapixels ?? fields.scaleMegapixels, 1)
: 0
const seed = fields.seed && String(fields.seed) !== 'random' const seed = fields.seed && String(fields.seed) !== 'random'
? Number(fields.seed) ? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647) : Math.floor(Math.random() * 2_147_483_647)
@@ -324,9 +331,10 @@ export default defineEventHandler(async (event) => {
queueAutoRun: false, queueAutoRun: false,
negative: String(fields.negative || '').trim(), negative: String(fields.negative || '').trim(),
passes: extraPasses, passes: extraPasses,
passMode,
referenceStillId: savedRef?.id, referenceStillId: savedRef?.id,
referenceStillFilename: savedRef?.filename, referenceStillFilename: savedRef?.filename,
scaleToTotalPixels: mode !== 'generate', scaleToTotalPixels: mode !== 'generate' && megapixels > 0,
scaleMegapixels: megapixels, scaleMegapixels: megapixels,
imagePipeline: 'v2', imagePipeline: 'v2',
v2Mode, v2Mode,
+2 -1
View File
@@ -140,7 +140,8 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
consistencyClip: clampImageV2Strength(rec.consistencyClip ?? rec.consistency_clip, mode === 'generate' ? 0 : IMAGE_V2_CONSISTENCY_CLIP), consistencyClip: clampImageV2Strength(rec.consistencyClip ?? rec.consistency_clip, mode === 'generate' ? 0 : IMAGE_V2_CONSISTENCY_CLIP),
steps: engine === 'krea' ? clampImageSteps(rec.steps, IMAGE_V2_KREA_STEPS) : turbo ? 8 : clampImageSteps(rec.steps, IMAGE_V2_STEPS_DEFAULT), steps: engine === 'krea' ? clampImageSteps(rec.steps, IMAGE_V2_KREA_STEPS) : turbo ? 8 : clampImageSteps(rec.steps, IMAGE_V2_STEPS_DEFAULT),
cfg: engine === 'krea' ? clampImageCfg(rec.cfg, IMAGE_V2_KREA_CFG) : turbo ? 1 : clampImageCfg(rec.cfg, IMAGE_V2_CFG_DEFAULT), cfg: engine === 'krea' ? clampImageCfg(rec.cfg, IMAGE_V2_KREA_CFG) : turbo ? 1 : clampImageCfg(rec.cfg, IMAGE_V2_CFG_DEFAULT),
megapixels: mode === 'generate' ? undefined : clampImageScaleMegapixels(rec.megapixels ?? rec.scaleMegapixels, 1), megapixels: mode === 'generate' || rec.scaleToTotalPixels === false ? undefined : clampImageScaleMegapixels(rec.megapixels ?? rec.scaleMegapixels, 1),
scaleToTotalPixels: mode === 'generate' ? undefined : rec.scaleToTotalPixels === false ? false : true,
turbo, turbo,
strength: mode === 'generate' || (mode !== 'refine' && engine !== 'krea') strength: mode === 'generate' || (mode !== 'refine' && engine !== 'krea')
? undefined ? undefined
+13 -6
View File
@@ -25,6 +25,7 @@ export type EditRunParams = {
loraStack?: import('~/utils/loras').LoraStackItem[] loraStack?: import('~/utils/loras').LoraStackItem[]
scaleToTotalPixels?: boolean scaleToTotalPixels?: boolean
scaleMegapixels?: number scaleMegapixels?: number
passMode?: 'batch' | 'chain'
} }
export async function runEdit(job: Job, params: EditRunParams) { export async function runEdit(job: Job, params: EditRunParams) {
@@ -32,6 +33,8 @@ export async function runEdit(job: Job, params: EditRunParams) {
if (!library) throw new Error('Edit job is missing library metadata') if (!library) throw new Error('Edit job is missing library metadata')
const prompts = [params.prompt, ...params.passes.map(item => item.prompt)] const prompts = [params.prompt, ...params.passes.map(item => item.prompt)]
const chainTotal = prompts.length const chainTotal = prompts.length
const batch = params.passMode !== 'chain'
const unit = batch ? 'Batch' : 'Pass'
library.chainTotal = chainTotal library.chainTotal = chainTotal
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
@@ -63,7 +66,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
for (let index = 0; index < prompts.length; index++) { for (let index = 0; index < prompts.length; index++) {
if (job.status === 'cancelled') throw new Error('Job interrupted.') if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (index > 0 && library.stopAfterCurrent === true) break if (index > 0 && library.stopAfterCurrent === true) break
const prompt = index === 0 ? composeFillPrompt(prompts[index], filled.padded) : prompts[index] const prompt = (batch || index === 0) ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
const last = index === prompts.length - 1 const last = index === prompts.length - 1
if (index > 0) { if (index > 0) {
await ensureComfyReady((status) => { await ensureComfyReady((status) => {
@@ -80,7 +83,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
library.seed = seed library.seed = seed
library.chainIndex = index library.chainIndex = index
library.chainStep = index + 1 library.chainStep = index + 1
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined library.chainLabel = chainTotal > 1 ? `${unit} ${index + 1}` : undefined
const reference = index === 0 ? params.reference : null const reference = index === 0 ? params.reference : null
const dual = Boolean(reference) const dual = Boolean(reference)
const passName = stillChainName(library.name || '', index) const passName = stillChainName(library.name || '', index)
@@ -163,7 +166,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
name: passName || undefined, name: passName || undefined,
prompt, prompt,
familyId: library.familyId, familyId: library.familyId,
parentStillId, parentStillId: batch ? library.stillId : parentStillId,
chainIndex: index, chainIndex: index,
settings: { settings: {
kind: 'image', kind: 'image',
@@ -180,7 +183,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
} }
}) })
job.stillId = still?.id job.stillId = still?.id
parentStillId = still?.id if (!batch) parentStillId = still?.id
await purgeComfyArtifacts({ await purgeComfyArtifacts({
video: { filename: output.filename, subfolder: output.subfolder, type: output.type }, video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
imageName: uploaded.name, imageName: uploaded.name,
@@ -192,17 +195,19 @@ export async function runEdit(job: Job, params: EditRunParams) {
if (!last) { if (!last) {
emitChainJob(job, { emitChainJob(job, {
type: 'checkpoint', type: 'checkpoint',
message: `Pass ${index + 1} saved`, message: `${unit} ${index + 1} of ${chainTotal} saved`,
progress: 100, progress: 100,
stillId: still?.id, stillId: still?.id,
hideThumbnail: job.hideThumbnail, hideThumbnail: job.hideThumbnail,
folderLocked: library.folderLocked folderLocked: library.folderLocked
}) })
if (!batch) {
current = { current = {
filename: still?.filename || `pass_${index + 1}.png`, filename: still?.filename || `pass_${index + 1}.png`,
data: buffer, data: buffer,
type: 'image/png' type: 'image/png'
} }
}
if (library.stopAfterCurrent === true) break if (library.stopAfterCurrent === true) break
continue continue
} }
@@ -210,7 +215,9 @@ export async function runEdit(job: Job, params: EditRunParams) {
job.status = 'complete' job.status = 'complete'
emitChainJob(job, { emitChainJob(job, {
type: 'complete', type: 'complete',
message: chainTotal > 1 ? 'Edit chain finished on Beast' : 'Edit finished on Beast', message: chainTotal > 1
? (batch ? 'Edit batch finished on Beast' : 'Edit chain finished on Beast')
: 'Edit finished on Beast',
progress: 100, progress: 100,
stillId: still?.id, stillId: still?.id,
filename: output.filename, filename: output.filename,
+17 -7
View File
@@ -37,6 +37,7 @@ export type EditV2RunParams = {
referenceStillId?: string referenceStillId?: string
loraStack?: import('~/utils/loras').LoraStackItem[] loraStack?: import('~/utils/loras').LoraStackItem[]
passes?: { prompt: string }[] passes?: { prompt: string }[]
passMode?: 'batch' | 'chain'
} }
export async function runEditV2(job: Job, params: EditV2RunParams) { export async function runEditV2(job: Job, params: EditV2RunParams) {
@@ -61,6 +62,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
: [] : []
const prompts = [params.prompt, ...extraPasses.map(item => item.prompt)] const prompts = [params.prompt, ...extraPasses.map(item => item.prompt)]
const chainTotal = prompts.length const chainTotal = prompts.length
const batch = params.passMode !== 'chain'
const unit = batch ? 'Batch' : 'Pass'
library.chainTotal = chainTotal library.chainTotal = chainTotal
library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId library.familyId = chainTotal > 1 ? (library.familyId || crypto.randomUUID()) : library.familyId
@@ -83,13 +86,14 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
const { applyImageFill } = await import('~/server/utils/imagePad') const { applyImageFill } = await import('~/server/utils/imagePad')
const { composeFillPrompt } = await import('~/utils/imageAspect') const { composeFillPrompt } = await import('~/utils/imageAspect')
let current = params.image || null let current = params.image || null
let startPadded = false
let mask = params.mask let mask = params.mask
let parentStillId: string | undefined let parentStillId: string | undefined
let uploadedRef: { name: string } | null = null let uploadedRef: { name: string } | null = null
let uploadedMask: { name: string } | null = null let uploadedMask: { name: string } | null = null
await ensureComfyLoraNames('image') await ensureComfyLoraNames('image')
if (!generate) await assertImageScaleToTotalPixelsNode() if (!generate && params.megapixels > 0) await assertImageScaleToTotalPixelsNode()
const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null
if (!generate && params.mode === 'compose' && params.reference) { if (!generate && params.mode === 'compose' && params.reference) {
@@ -108,7 +112,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
library.seed = seed library.seed = seed
library.chainIndex = index library.chainIndex = index
library.chainStep = index + 1 library.chainStep = index + 1
library.chainLabel = chainTotal > 1 ? `Pass ${index + 1}` : undefined library.chainLabel = chainTotal > 1 ? `${unit} ${index + 1}` : undefined
const passName = stillChainName(library.name || '', index) const passName = stillChainName(library.name || '', index)
let prompt = prompts[index] let prompt = prompts[index]
let image = current let image = current
@@ -151,6 +155,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
}, job.id) }, job.id)
} }
padded = filled.padded padded = filled.padded
startPadded = filled.padded
prompt = composeFillPrompt(prompt, padded) prompt = composeFillPrompt(prompt, padded)
if (padded) { if (padded) {
emitChainJob(job, { emitChainJob(job, {
@@ -159,6 +164,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
progress: 7 progress: 7
}) })
} }
} else if (batch && startPadded) {
prompt = composeFillPrompt(prompt, true)
} }
if (index === 0 && params.mode === 'refine' && mask && !uploadedMask) { if (index === 0 && params.mode === 'refine' && mask && !uploadedMask) {
@@ -258,7 +265,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
name: passName || undefined, name: passName || undefined,
prompt, prompt,
familyId: library.familyId, familyId: library.familyId,
parentStillId, parentStillId: batch ? (params.sourceStillId || library.stillId) : parentStillId,
chainIndex: index, chainIndex: index,
settings: { settings: {
kind: 'imagev2', kind: 'imagev2',
@@ -272,7 +279,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
consistencyClip: params.consistencyClip, consistencyClip: params.consistencyClip,
steps: params.steps, steps: params.steps,
cfg: params.cfg, cfg: params.cfg,
megapixels: params.mode === 'generate' ? undefined : params.megapixels, megapixels: params.mode === 'generate' || !(params.megapixels > 0) ? undefined : params.megapixels,
scaleToTotalPixels: params.mode === 'generate' ? undefined : params.megapixels > 0,
turbo: params.turbo === true, turbo: params.turbo === true,
strength: params.mode === 'generate' ? undefined : params.strength, strength: params.mode === 'generate' ? undefined : params.strength,
width: params.mode === 'generate' ? params.width : undefined, width: params.mode === 'generate' ? params.width : undefined,
@@ -285,7 +293,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
} }
}) })
job.stillId = still?.id job.stillId = still?.id
parentStillId = still?.id if (!batch) parentStillId = still?.id
await purgeComfyArtifacts({ await purgeComfyArtifacts({
video: { filename: output.filename, subfolder: output.subfolder, type: output.type }, video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
imageName: uploaded?.name, imageName: uploaded?.name,
@@ -299,17 +307,19 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
if (!last) { if (!last) {
emitChainJob(job, { emitChainJob(job, {
type: 'checkpoint', type: 'checkpoint',
message: `Pass ${index + 1} saved`, message: `${unit} ${index + 1} of ${chainTotal} saved`,
progress: 100, progress: 100,
stillId: still?.id, stillId: still?.id,
hideThumbnail: job.hideThumbnail, hideThumbnail: job.hideThumbnail,
folderLocked: library.folderLocked folderLocked: library.folderLocked
}) })
if (!batch) {
current = { current = {
filename: still?.filename || `pass_${index + 1}.png`, filename: still?.filename || `pass_${index + 1}.png`,
data: buffer, data: buffer,
type: 'image/png' type: 'image/png'
} }
}
if (library.stopAfterCurrent === true) break if (library.stopAfterCurrent === true) break
continue continue
} }
@@ -318,7 +328,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
emitChainJob(job, { emitChainJob(job, {
type: 'complete', type: 'complete',
message: chainTotal > 1 message: chainTotal > 1
? `${engineLabel} v2 chain finished on Beast` ? `${engineLabel} v2 ${batch ? 'batch' : 'chain'} finished on Beast`
: `${engineLabel} v2 finished on Beast`, : `${engineLabel} v2 finished on Beast`,
progress: 100, progress: 100,
stillId: still?.id, stillId: still?.id,
+32 -4
View File
@@ -110,6 +110,34 @@ function patchScaleMegapixels(graph: WorkflowGraph, megapixels: number) {
} }
} }
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 applyScaleMegapixels(graph: WorkflowGraph, megapixels?: number) {
const mp = Number(megapixels)
if (Number.isFinite(mp) && mp > 0) {
patchScaleMegapixels(graph, mp)
return
}
stripScaleNodes(graph)
}
function graphHasMaskInput(graph: WorkflowGraph) { function graphHasMaskInput(graph: WorkflowGraph) {
return Object.values(graph).some((node) => { return Object.values(graph).some((node) => {
const mask = node.inputs?.mask const mask = node.inputs?.mask
@@ -401,7 +429,7 @@ function buildKreaItiWorkflow(params: ImageV2BuildParams) {
setInput(graph, '15', 'scheduler', 'simple') setInput(graph, '15', 'scheduler', 'simple')
setInput(graph, '15', 'denoise', denoise) setInput(graph, '15', 'denoise', denoise)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || `v2-krea-${params.mode}`) setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || `v2-krea-${params.mode}`)
patchScaleMegapixels(graph, params.megapixels ?? 1) applyScaleMegapixels(graph, params.megapixels)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea') const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'krea'), 'krea')
assertImageV2Graph(graph, params.mode, compose ? params.imageBName : undefined, 'krea') assertImageV2Graph(graph, params.mode, compose ? params.imageBName : undefined, 'krea')
@@ -420,7 +448,7 @@ function buildKreaItiWorkflow(params: ImageV2BuildParams) {
steps, steps,
cfg, cfg,
seed: params.seed, seed: params.seed,
megapixels: clampImageScaleMegapixels(params.megapixels ?? 1) megapixels: Number(params.megapixels) > 0 ? clampImageScaleMegapixels(params.megapixels) : 0
})) }))
return { graph, workflowFile, loaders, prompt, strength: denoise } return { graph, workflowFile, loaders, prompt, strength: denoise }
} }
@@ -513,7 +541,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
setInput(graph, SCHEDULER, 'steps', steps) setInput(graph, SCHEDULER, 'steps', steps)
setInput(graph, CFG, 'cfg', cfg) setInput(graph, CFG, 'cfg', cfg)
setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || (generate ? 'v2-generate' : 'v2')) setInput(graph, SAVE, 'filename_prefix', params.filenamePrefix || (generate ? 'v2-generate' : 'v2'))
if (!generate) patchScaleMegapixels(graph, params.megapixels ?? 1) if (!generate) applyScaleMegapixels(graph, params.megapixels)
const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'flux'), 'flux') const userLoras = applyImageV2UserLoras(graph, imageV2UserStack(params, 'flux'), 'flux')
const guider = Object.values(graph).find(node => node.class_type === 'CFGGuider' || node.class_type === 'BasicGuider') const guider = Object.values(graph).find(node => node.class_type === 'CFGGuider' || node.class_type === 'BasicGuider')
@@ -538,7 +566,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
steps, steps,
cfg, cfg,
seed: params.seed, seed: params.seed,
megapixels: generate ? undefined : clampImageScaleMegapixels(params.megapixels ?? 1) megapixels: generate ? undefined : (Number(params.megapixels) > 0 ? clampImageScaleMegapixels(params.megapixels) : 0)
})) }))
return { graph, workflowFile, loaders, prompt, strength } return { graph, workflowFile, loaders, prompt, strength }
+8 -3
View File
@@ -47,6 +47,7 @@ export interface StudioJobPayload {
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][] shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
negative?: string negative?: string
passes?: { prompt: string }[] passes?: { prompt: string }[]
passMode?: 'batch' | 'chain'
referenceStillId?: string referenceStillId?: string
referenceStillFilename?: string referenceStillFilename?: string
scaleToTotalPixels?: boolean scaleToTotalPixels?: boolean
@@ -781,7 +782,9 @@ async function startStudioEditJob(item: StudioJob) {
chainIndex: 0, chainIndex: 0,
chainStep: 1, chainStep: 1,
chainTotal: 1 + ((mode === 'edit' || mode === 'compose') ? (payload.passes?.length || 0) : 0), chainTotal: 1 + ((mode === 'edit' || mode === 'compose') ? (payload.passes?.length || 0) : 0),
chainLabel: (mode === 'edit' || mode === 'compose') && payload.passes?.length ? 'Pass 1' : undefined, chainLabel: (mode === 'edit' || mode === 'compose') && payload.passes?.length
? (payload.passMode === 'chain' ? 'Pass 1' : 'Batch 1')
: undefined,
passes: payload.passes, passes: payload.passes,
...persistLoraFields(payload.loraStack || payload.loraName) ...persistLoraFields(payload.loraStack || payload.loraName)
} }
@@ -796,6 +799,7 @@ async function startStudioEditJob(item: StudioJob) {
mask, mask,
prompt: payload.prompt, prompt: payload.prompt,
passes: payload.passes || [], passes: payload.passes || [],
passMode: payload.passMode === 'chain' ? 'chain' : 'batch',
negative: payload.negative || '', negative: payload.negative || '',
steps: payload.steps, steps: payload.steps,
seed: live.library?.seed || payload.seed, seed: live.library?.seed || payload.seed,
@@ -804,7 +808,7 @@ async function startStudioEditJob(item: StudioJob) {
snofsClip: payload.snofsClip ?? 0, snofsClip: payload.snofsClip ?? 0,
consistencyModel: payload.consistencyModel ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7), consistencyModel: payload.consistencyModel ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7),
consistencyClip: payload.consistencyClip ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7), consistencyClip: payload.consistencyClip ?? (mode === 'generate' || payload.engine === 'krea' ? 0 : 0.7),
megapixels: payload.scaleMegapixels ?? 1, megapixels: payload.scaleToTotalPixels === false ? 0 : (payload.scaleMegapixels ?? 1),
strength: payload.refineStrength, strength: payload.refineStrength,
aspect: payload.aspect || 'auto', aspect: payload.aspect || 'auto',
width: payload.width, width: payload.width,
@@ -848,7 +852,7 @@ async function startStudioEditJob(item: StudioJob) {
chainIndex: 0, chainIndex: 0,
chainStep: 1, chainStep: 1,
chainTotal: 1 + passes.length, chainTotal: 1 + passes.length,
chainLabel: passes.length ? 'Pass 1' : undefined, chainLabel: passes.length ? (payload.passMode === 'chain' ? 'Pass 1' : 'Batch 1') : undefined,
passes, passes,
...persistLoraFields(payload.loraStack || payload.loraName) ...persistLoraFields(payload.loraStack || payload.loraName)
} }
@@ -860,6 +864,7 @@ async function startStudioEditJob(item: StudioJob) {
reference, reference,
prompt: payload.prompt, prompt: payload.prompt,
passes, passes,
passMode: payload.passMode === 'chain' ? 'chain' : 'batch',
negative: payload.negative || '', negative: payload.negative || '',
steps: payload.steps, steps: payload.steps,
seed: live.library?.seed || payload.seed, seed: live.library?.seed || payload.seed,
+6
View File
@@ -37,6 +37,12 @@ export const IMAGE_SCALE_MP_MIN = 0.01
export const IMAGE_SCALE_MP_MAX = 16 export const IMAGE_SCALE_MP_MAX = 16
export const IMAGE_SCALE_MP_STEP = 0.01 export const IMAGE_SCALE_MP_STEP = 0.01
export const IMAGE_SCALE_MP_DEFAULT = 1 export const IMAGE_SCALE_MP_DEFAULT = 1
export const IMAGE_PASS_MODES = ['batch', 'chain'] as const
export type ImagePassMode = (typeof IMAGE_PASS_MODES)[number]
export function parseImagePassMode(raw: unknown): ImagePassMode {
return raw === 'chain' ? 'chain' : 'batch'
}
export const VIDEO_CFG_MIN = 0 export const VIDEO_CFG_MIN = 0
export const VIDEO_CFG_MAX = 20 export const VIDEO_CFG_MAX = 20
+1
View File
@@ -170,6 +170,7 @@ export type ImageV2PresetSettings = {
steps?: number steps?: number
cfg?: number cfg?: number
megapixels?: number megapixels?: number
scaleToTotalPixels?: boolean
turbo?: boolean turbo?: boolean
strength?: number strength?: number
width?: number width?: number
+1 -1
View File
@@ -169,7 +169,7 @@ export function stillSettingsRows(still: { width?: number; height?: number; sett
if (settings.mode === 'refine' && settings.strength != null) { if (settings.mode === 'refine' && settings.strength != null) {
rows.push({ label: 'Strength', value: formatNum(settings.strength) }) rows.push({ label: 'Strength', value: formatNum(settings.strength) })
} }
if (settings.mode !== 'generate' && settings.megapixels != null) { if (settings.mode !== 'generate' && settings.scaleToTotalPixels !== false && settings.megapixels != null && settings.megapixels > 0) {
rows.push({ label: 'Scale to MP', value: formatNum(settings.megapixels) }) rows.push({ label: 'Scale to MP', value: formatNum(settings.megapixels) })
} }
} else { } else {