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:
@@ -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>
|
||||
@@ -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>
|
||||
</div>
|
||||
<input
|
||||
class="aigen-slider mt-3 w-full"
|
||||
type="range"
|
||||
<div class="mt-3">
|
||||
<Thumbwheel
|
||||
:model-value="slider"
|
||||
:min="SLIDER_MIN"
|
||||
:max="SLIDER_MAX"
|
||||
:step="0.1"
|
||||
:value="slider"
|
||||
:style="{ background: `linear-gradient(to right, #fbbf24 ${sliderPct}%, #27272a ${sliderPct}%)` }"
|
||||
:aria-label="label || 'Duration in seconds'"
|
||||
@input="onSlider"
|
||||
>
|
||||
compact
|
||||
@update:model-value="onSliderValue"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="hint" class="mt-1 text-[11px] text-zinc-500">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,7 +50,6 @@ const draft = ref(formatDuration(model.value))
|
||||
const scrubbing = ref(false)
|
||||
|
||||
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) => {
|
||||
if (!scrubbing.value) draft.value = formatDuration(value)
|
||||
@@ -72,8 +70,8 @@ function commit() {
|
||||
draft.value = formatDuration(model.value)
|
||||
}
|
||||
|
||||
function onSlider(event: Event) {
|
||||
model.value = clampDuration(Number((event.target as HTMLInputElement).value))
|
||||
function onSliderValue(value: number) {
|
||||
model.value = clampDuration(value)
|
||||
draft.value = formatDuration(model.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -42,39 +42,27 @@
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1.5 grid grid-cols-2 gap-2">
|
||||
<label class="block min-w-0">
|
||||
<span class="mb-0.5 flex justify-between text-[10px] uppercase tracking-wider text-zinc-500">
|
||||
<span>Model</span>
|
||||
<span class="tabular-nums normal-case text-zinc-400">{{ formatLoraStrength(item.strengthModel) }}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
class="aigen-slider w-full"
|
||||
<div class="mt-1.5 flex flex-wrap gap-3">
|
||||
<Thumbwheel
|
||||
:model-value="item.strengthModel"
|
||||
label="Model"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:value="item.strengthModel"
|
||||
compact
|
||||
:disabled="disabled"
|
||||
@input="setStrength(index, 'strengthModel', $event)"
|
||||
>
|
||||
</label>
|
||||
<label class="block min-w-0" :class="{ 'opacity-50': !clipStrength }">
|
||||
<span class="mb-0.5 flex justify-between text-[10px] uppercase tracking-wider text-zinc-500">
|
||||
<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"
|
||||
@update:model-value="setStrength(index, 'strengthModel', $event)"
|
||||
/>
|
||||
<Thumbwheel
|
||||
:model-value="item.strengthClip"
|
||||
label="CLIP"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:value="item.strengthClip"
|
||||
compact
|
||||
:disabled="disabled || !clipStrength"
|
||||
@input="setStrength(index, 'strengthClip', $event)"
|
||||
>
|
||||
</label>
|
||||
@update:model-value="setStrength(index, 'strengthClip', $event)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -107,7 +95,6 @@ import {
|
||||
LORA_STRENGTH_MIN,
|
||||
LORA_STRENGTH_STEP,
|
||||
clampLoraStrength,
|
||||
formatLoraStrength,
|
||||
loraDisplayName,
|
||||
loraIdentityKey,
|
||||
makeLoraStackItem,
|
||||
@@ -208,8 +195,8 @@ function onDragEnd() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||
const value = clampLoraStrength((event.target as HTMLInputElement).value)
|
||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: value } : item)
|
||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
||||
const next = clampLoraStrength(value)
|
||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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)]">
|
||||
<section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
|
||||
<div>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<div class="mb-4 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||
@click="studioMode = 'video'; outputFocus = 'video'"
|
||||
class="rounded-3xl border px-4 py-4 text-left transition"
|
||||
: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="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
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||
@click="studioMode = 'edit'; outputFocus = 'edit'"
|
||||
class="rounded-3xl border px-4 py-4 text-left transition"
|
||||
: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="selectStudioKind('image')"
|
||||
>
|
||||
Image edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
:class="studioMode === 'editv2' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||
@click="studioMode = 'editv2'; outputFocus = 'edit'"
|
||||
>
|
||||
Image v2
|
||||
<svg class="h-10 w-10" viewBox="0 0 40 40" fill="none" aria-hidden="true">
|
||||
<rect x="6" y="8" width="28" height="24" rx="3" stroke="currentColor" stroke-width="2" />
|
||||
<path d="m10 26 7-8 6 7 3-3 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="15" cy="15" r="2.5" fill="currentColor" />
|
||||
</svg>
|
||||
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Image</span>
|
||||
<span class="mt-0.5 block text-xs text-zinc-500">Flux · Krea</span>
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||||
@@ -335,13 +339,33 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
v-for="(item, index) in editPassQueue"
|
||||
:key="item.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
class="text-xs text-red-300 hover:text-red-200"
|
||||
@@ -615,7 +639,7 @@
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-zinc-500">{{ videoModelHint }}</p>
|
||||
</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>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p>
|
||||
<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>
|
||||
<AspectRatioPick :model-value="aspect" heading="Aspect ratio" :options="imageAspects" @update:model-value="setImageAspect" />
|
||||
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
|
||||
</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">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-zinc-300">Strength (denoise)</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="v2Denoise"
|
||||
type="range"
|
||||
<Thumbwheel
|
||||
v-model="v2Denoise"
|
||||
:min="IMAGE_V2_DENOISE_MIN"
|
||||
:max="IMAGE_V2_DENOISE_MAX"
|
||||
:step="IMAGE_V2_DENOISE_STEP"
|
||||
class="w-full"
|
||||
>
|
||||
<span class="w-12 tabular-nums text-sm text-zinc-200">{{ v2Denoise }}</span>
|
||||
</div>
|
||||
:digits="2"
|
||||
compact
|
||||
/>
|
||||
</label>
|
||||
<div v-if="v2Mode === 'refine'" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -809,24 +817,46 @@
|
||||
</button>
|
||||
</div>
|
||||
</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="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>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-zinc-400">CFG</span>
|
||||
<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">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<div class="flex flex-wrap items-start gap-x-4 gap-y-3">
|
||||
<Thumbwheel
|
||||
v-model="v2Steps"
|
||||
label="Steps"
|
||||
:min="IMAGE_STEPS_MIN"
|
||||
:max="IMAGE_STEPS_MAX"
|
||||
:step="1"
|
||||
:fine-step="1"
|
||||
:coarse-step="5"
|
||||
: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>
|
||||
<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 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">
|
||||
<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">
|
||||
@@ -847,22 +877,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio</p>
|
||||
<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>
|
||||
<AspectRatioPick :model-value="aspect" heading="Aspect ratio" :options="imageAspects" @update:model-value="setImageAspect" />
|
||||
<p class="mt-2 text-xs text-zinc-500">{{ imageAspectHelp }}</p>
|
||||
</div>
|
||||
<div class="max-w-md space-y-2">
|
||||
@@ -932,20 +949,7 @@
|
||||
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Aspect ratio / resolution</p>
|
||||
<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>
|
||||
<AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" />
|
||||
<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>
|
||||
@@ -956,30 +960,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="mb-1 block text-zinc-400">CFG / Guidance scale</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
class="aigen-slider min-w-0 flex-1"
|
||||
type="range"
|
||||
<Thumbwheel
|
||||
v-model="cfg"
|
||||
label="CFG / Guidance scale"
|
||||
:min="CFG_MIN"
|
||||
:max="CFG_MAX"
|
||||
:step="CFG_STEP"
|
||||
:value="cfg"
|
||||
:style="{ background: `linear-gradient(to right, #fbbf24 ${cfgPct}%, #27272a ${cfgPct}%)` }"
|
||||
aria-label="CFG guidance scale"
|
||||
@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>
|
||||
@update:model-value="cfgTouched = true"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="text-sm">
|
||||
@@ -996,7 +985,7 @@
|
||||
label="Duration"
|
||||
:hint="shotScriptMode
|
||||
? '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>
|
||||
<label class="text-sm">
|
||||
@@ -1326,7 +1315,7 @@
|
||||
<DurationField
|
||||
v-model="extendDuration"
|
||||
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" />
|
||||
<button
|
||||
@@ -2007,11 +1996,13 @@ import {
|
||||
IMAGE_CFG_MAX,
|
||||
IMAGE_CFG_MIN,
|
||||
IMAGE_CFG_STEP,
|
||||
IMAGE_SCALE_MP_DEFAULT,
|
||||
IMAGE_SCALE_MP_MAX,
|
||||
IMAGE_SCALE_MP_MIN,
|
||||
IMAGE_SCALE_MP_STEP,
|
||||
IMAGE_STEPS_MAX,
|
||||
IMAGE_STEPS_MIN,
|
||||
type ImagePassMode,
|
||||
VIDEO_CFG_MAX,
|
||||
VIDEO_CFG_MIN,
|
||||
VIDEO_CFG_STEP,
|
||||
@@ -2271,6 +2262,7 @@ const editPassesBeforeRecommend = ref<QueuedEditPass[]>([])
|
||||
const shotScriptBeforeRecommend = ref(false)
|
||||
const extensionQueue = ref<QueuedExtension[]>([])
|
||||
const editPassQueue = ref<QueuedEditPass[]>([])
|
||||
const imagePassMode = ref<ImagePassMode>('batch')
|
||||
const clipName = ref('')
|
||||
const videoDownloadName = ref('output')
|
||||
const editDownloadName = ref('edited')
|
||||
@@ -2317,6 +2309,18 @@ const videoBusy = ref(false)
|
||||
const editBusy = ref(false)
|
||||
const studioMode = ref('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 imageComfyBusy = ref(false)
|
||||
const imageComfyConfigured = ref(false)
|
||||
@@ -2367,7 +2371,8 @@ const v2Task = ref<ImageV2Task>('scene')
|
||||
const v2Negative = ref('')
|
||||
const v2Steps = ref(IMAGE_V2_STEPS_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 v2Width = ref(IMAGE_V2_GENERATE_WIDTH)
|
||||
const v2Height = ref(IMAGE_V2_GENERATE_HEIGHT)
|
||||
@@ -2501,7 +2506,7 @@ const pendingLibraryDelete = ref<PendingLibraryDelete | null>(null)
|
||||
const libraryDeleteBusy = ref(false)
|
||||
const videoEngine = ref<VideoEngineId>('minimax')
|
||||
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 textToVideo = computed(() => isTextToVideo(videoWorkflow.value))
|
||||
const ltxVideo = computed(() => isLtxWorkflow(videoWorkflow.value))
|
||||
@@ -2718,6 +2723,10 @@ const autoHint = computed(() => {
|
||||
const size = autoResolution(imageWidth.value, imageHeight.value)
|
||||
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(() => {
|
||||
if (ltxVideo.value) {
|
||||
return textToVideo.value
|
||||
@@ -2819,29 +2828,37 @@ const editV2BlockReason = computed(() => {
|
||||
})
|
||||
const v2PassChaining = computed(() => studioMode.value === 'editv2' && (v2Mode.value === 'edit' || v2Mode.value === 'compose'))
|
||||
const v2ModeLocked = computed(() => v2PassChaining.value && editPassQueue.value.length > 0)
|
||||
const extraPassButtonLabel = computed(() => (
|
||||
studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass'
|
||||
))
|
||||
const extraPassButtonLabel = computed(() => {
|
||||
if (imagePassMode.value === 'batch') return '+ Prompt'
|
||||
return studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass'
|
||||
})
|
||||
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') {
|
||||
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.'
|
||||
})
|
||||
const extraPassPlaceholder = computed(() => (
|
||||
studioMode.value === 'editv2' && v2Mode.value === 'compose'
|
||||
const extraPassPlaceholder = computed(() => {
|
||||
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 edit prompt, applied to the previous pass output…'
|
||||
))
|
||||
})
|
||||
const editV2SubmitLabel = computed(() => {
|
||||
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
|
||||
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'
|
||||
if (!occupied) {
|
||||
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`
|
||||
}
|
||||
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()}`
|
||||
})
|
||||
const generateLabel = computed(() => {
|
||||
@@ -2865,13 +2882,15 @@ const queuedExtensionCount = computed(() => (
|
||||
const editLabel = computed(() => {
|
||||
const n = editPassQueue.value.length
|
||||
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`
|
||||
})
|
||||
const editSubmitLabel = computed(() => {
|
||||
const occupied = videoBusy.value || editBusy.value || studioJobs.value.some(job => job.status === 'running')
|
||||
if (!occupied) return editLabel.value
|
||||
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 recommendedShots = computed(() => {
|
||||
@@ -3468,12 +3487,12 @@ onMounted(async () => {
|
||||
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
|
||||
videoEngine.value = 'minimax'
|
||||
videoStart.value = 'still'
|
||||
minimaxGraph.value = 'v1'
|
||||
minimaxGraph.value = 'v2'
|
||||
try {
|
||||
const saved = parseVideoWorkflow(localStorage.getItem('aigen-video-workflow') || '')
|
||||
videoEngine.value = videoEngineOf(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 start = localStorage.getItem('aigen-video-start')
|
||||
if (engine === 'ltx' || engine === 'minimax') videoEngine.value = engine
|
||||
@@ -3686,6 +3705,11 @@ function onEditScaleMegapixels() {
|
||||
editScaleMegapixels.value = clampImageScaleMegapixels(editScaleMegapixels.value)
|
||||
}
|
||||
|
||||
function setV2ScaleOn(on: boolean) {
|
||||
v2ScaleOn.value = on
|
||||
if (on) v2Megapixels.value = IMAGE_SCALE_MP_DEFAULT
|
||||
}
|
||||
|
||||
function onCfgSlider(event: Event) {
|
||||
cfgTouched.value = true
|
||||
cfg.value = clampCfg(Number((event.target as HTMLInputElement).value))
|
||||
@@ -3919,7 +3943,8 @@ function currentPresetSnapshot() {
|
||||
consistencyClip: imageV2EngineLoras().find(item => isImageV2ConsistencyLora(item.name))?.strengthClip ?? 0,
|
||||
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),
|
||||
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,
|
||||
strength: v2Mode.value === 'generate' || (v2Mode.value !== 'refine' && v2Engine.value !== 'krea') ? undefined : clampImageV2Denoise(v2Denoise.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)
|
||||
v2Cfg.value = clampImageCfg(settings.cfg, v2Engine.value === 'krea' ? IMAGE_V2_KREA_CFG : IMAGE_V2_CFG_DEFAULT)
|
||||
v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
|
||||
v2ScaleOn.value = v2Mode.value !== 'generate' && settings.scaleToTotalPixels !== false
|
||||
v2Turbo.value = settings.turbo === true
|
||||
if (v2Mode.value === 'refine' || (v2Engine.value === 'krea' && v2Mode.value !== 'generate')) v2Denoise.value = clampImageV2Denoise(settings.strength)
|
||||
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.cfg != null) v2Cfg.value = clampImageCfg(settings.cfg, IMAGE_V2_CFG_DEFAULT)
|
||||
if (settings.megapixels != null) v2Megapixels.value = clampImageScaleMegapixels(settings.megapixels, 1)
|
||||
v2ScaleOn.value = mode !== 'generate' && settings.scaleToTotalPixels !== false
|
||||
v2Turbo.value = settings.turbo === true
|
||||
if ((mode === 'refine' || (v2Engine.value === 'krea' && mode !== 'generate')) && settings.strength != null) v2Denoise.value = clampImageV2Denoise(settings.strength)
|
||||
aspect.value = parseImageAspect(settings.aspect)
|
||||
@@ -5798,6 +5825,19 @@ function removeEditPass(id: string) {
|
||||
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() {
|
||||
if (!file.value || (!prompt.value.trim() && !imageFillWithoutPrompt.value)) {
|
||||
toast(editBlockReason.value || 'Load a still first.')
|
||||
@@ -5831,6 +5871,7 @@ async function editImage() {
|
||||
body.append('name', clipName.value.trim())
|
||||
if (inputStillId.value) body.append('stillId', inputStillId.value)
|
||||
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
|
||||
body.append('passMode', imagePassMode.value)
|
||||
if (imageLoraStack.value.length) body.append('loraStack', JSON.stringify(imageLoraStack.value))
|
||||
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
|
||||
const started = await $fetch<{
|
||||
@@ -5847,7 +5888,9 @@ async function editImage() {
|
||||
if (started.queued) {
|
||||
const n = 1 + extraPasses.length
|
||||
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.')
|
||||
await refreshStudioQueue()
|
||||
return
|
||||
@@ -5865,13 +5908,10 @@ async function editImage() {
|
||||
editMaxStep.value = started.steps
|
||||
editChainStep.value = 1
|
||||
editChainTotal.value = 1 + extraPasses.length
|
||||
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
|
||||
editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
|
||||
editOverallProgress.value = 0
|
||||
editCompletedChainStep.value = 0
|
||||
editActiveChainPlan.value = [
|
||||
{ label: 'Pass 1', prompt: prompt.value.trim() },
|
||||
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
|
||||
]
|
||||
editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, 'Pass 1')
|
||||
startTimer('edit')
|
||||
editDownloadName.value = downloadName
|
||||
editJobId.value = started.jobId
|
||||
@@ -5936,9 +5976,9 @@ function setV2Engine(engine: ImageV2Engine) {
|
||||
}
|
||||
}
|
||||
|
||||
function setImageAspect(id: AspectId) {
|
||||
aspect.value = id
|
||||
const size = generateSizeForAspect(id)
|
||||
function setImageAspect(id: string) {
|
||||
aspect.value = normalizeAspect(id)
|
||||
const size = generateSizeForAspect(aspect.value)
|
||||
v2Width.value = size.width
|
||||
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('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)))
|
||||
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('seed', seedInput.value || 'random')
|
||||
body.append('folderId', folderId.value)
|
||||
@@ -6069,6 +6112,7 @@ async function editImageV2() {
|
||||
body.append('name', clipName.value.trim())
|
||||
if (inputStillId.value) body.append('stillId', inputStillId.value)
|
||||
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
|
||||
body.append('passMode', imagePassMode.value)
|
||||
const v2Stack = filterLoraStackForImageEngine(
|
||||
filterLoraStackForStudio(imageV2LoraStack.value, ltxEnabled.value),
|
||||
v2Engine.value
|
||||
@@ -6096,7 +6140,9 @@ async function editImageV2() {
|
||||
if (started.queued) {
|
||||
const n = 1 + extraPasses.length
|
||||
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.'))
|
||||
await refreshStudioQueue()
|
||||
return
|
||||
@@ -6114,16 +6160,11 @@ async function editImageV2() {
|
||||
editMaxStep.value = started.steps
|
||||
editChainStep.value = 1
|
||||
editChainTotal.value = 1 + extraPasses.length
|
||||
editChainLabel.value = extraPasses.length ? 'Pass 1' : ''
|
||||
editChainLabel.value = extraPasses.length ? `${imagePassUnit()} 1` : ''
|
||||
editOverallProgress.value = 0
|
||||
editCompletedChainStep.value = 0
|
||||
const passNoun = v2Mode.value === 'compose' ? 'Compose' : v2Mode.value === 'refine' ? 'Refine' : v2Mode.value === 'generate' ? 'Generate' : 'Edit'
|
||||
editActiveChainPlan.value = extraPasses.length
|
||||
? [
|
||||
{ label: 'Pass 1', prompt: prompt.value.trim() },
|
||||
...extraPasses.map((item, index) => ({ label: `Pass ${index + 2}`, prompt: item.prompt }))
|
||||
]
|
||||
: [{ label: passNoun, prompt: prompt.value.trim() }]
|
||||
editActiveChainPlan.value = imagePassPlan(prompt.value.trim(), extraPasses, passNoun)
|
||||
startTimer('edit')
|
||||
editDownloadName.value = downloadName
|
||||
editJobId.value = started.jobId
|
||||
@@ -6181,7 +6222,7 @@ function applyVideoWorkflow(raw?: string) {
|
||||
const parsed = coerceVideoWorkflow(raw, ltxEnabled.value)
|
||||
videoEngine.value = videoEngineOf(parsed)
|
||||
videoStart.value = videoStartOf(parsed)
|
||||
minimaxGraph.value = minimaxGraphOf(parsed)
|
||||
minimaxGraph.value = minimaxGraphOf(parsed) === 'v1' ? 'v2' : minimaxGraphOf(parsed)
|
||||
}
|
||||
|
||||
async function ensureVideoInputStill() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parsePostedLoraStack, listStudioLoras, persistLoraFields } from '~/serv
|
||||
import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/utils/comfy'
|
||||
import { imageDimensions } from '~/server/utils/resolution'
|
||||
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) {
|
||||
if (!raw) return [] as { prompt: string }[]
|
||||
@@ -35,6 +35,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const prompt = (fields.prompt || '').trim()
|
||||
const extraPasses = parsePasses(fields.passes)
|
||||
const passMode = parseImagePassMode(fields.passMode)
|
||||
const aspect = parseImageAspect(fields.aspect)
|
||||
if (!image) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
|
||||
@@ -152,6 +153,7 @@ export default defineEventHandler(async (event) => {
|
||||
queueAutoRun: false,
|
||||
negative: (fields.negative || '').trim(),
|
||||
passes,
|
||||
passMode,
|
||||
referenceStillId: savedRef?.id,
|
||||
referenceStillFilename: savedRef?.filename,
|
||||
scaleToTotalPixels,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/
|
||||
import { comfyConfigured } from '~/server/utils/comfy'
|
||||
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
|
||||
import { imageDimensions } from '~/server/utils/resolution'
|
||||
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps } from '~/utils/generationPresets'
|
||||
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImagePassMode } from '~/utils/generationPresets'
|
||||
import {
|
||||
IMAGE_V2_CFG_DEFAULT,
|
||||
IMAGE_V2_CONSISTENCY_CLIP,
|
||||
@@ -137,6 +137,7 @@ export default defineEventHandler(async (event) => {
|
||||
)
|
||||
const prompt = String(fields.prompt || '').trim()
|
||||
const extraPasses = parsePasses(fields.passes)
|
||||
const passMode = parseImagePassMode(fields.passMode)
|
||||
const aspect = parseImageAspect(fields.aspect)
|
||||
if (!prompt && (mode === 'generate' || aspect === 'auto')) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
|
||||
@@ -213,7 +214,13 @@ export default defineEventHandler(async (event) => {
|
||||
const cfg = engine === 'krea'
|
||||
? clampImageCfg(fields.cfg, IMAGE_V2_KREA_CFG)
|
||||
: 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'
|
||||
? Number(fields.seed)
|
||||
: Math.floor(Math.random() * 2_147_483_647)
|
||||
@@ -324,9 +331,10 @@ export default defineEventHandler(async (event) => {
|
||||
queueAutoRun: false,
|
||||
negative: String(fields.negative || '').trim(),
|
||||
passes: extraPasses,
|
||||
passMode,
|
||||
referenceStillId: savedRef?.id,
|
||||
referenceStillFilename: savedRef?.filename,
|
||||
scaleToTotalPixels: mode !== 'generate',
|
||||
scaleToTotalPixels: mode !== 'generate' && megapixels > 0,
|
||||
scaleMegapixels: megapixels,
|
||||
imagePipeline: 'v2',
|
||||
v2Mode,
|
||||
|
||||
@@ -140,7 +140,8 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
|
||||
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),
|
||||
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,
|
||||
strength: mode === 'generate' || (mode !== 'refine' && engine !== 'krea')
|
||||
? undefined
|
||||
|
||||
@@ -25,6 +25,7 @@ export type EditRunParams = {
|
||||
loraStack?: import('~/utils/loras').LoraStackItem[]
|
||||
scaleToTotalPixels?: boolean
|
||||
scaleMegapixels?: number
|
||||
passMode?: 'batch' | 'chain'
|
||||
}
|
||||
|
||||
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')
|
||||
const prompts = [params.prompt, ...params.passes.map(item => item.prompt)]
|
||||
const chainTotal = prompts.length
|
||||
const batch = params.passMode !== 'chain'
|
||||
const unit = batch ? 'Batch' : 'Pass'
|
||||
library.chainTotal = chainTotal
|
||||
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++) {
|
||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||
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
|
||||
if (index > 0) {
|
||||
await ensureComfyReady((status) => {
|
||||
@@ -80,7 +83,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
|
||||
library.seed = seed
|
||||
library.chainIndex = index
|
||||
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 dual = Boolean(reference)
|
||||
const passName = stillChainName(library.name || '', index)
|
||||
@@ -163,7 +166,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
|
||||
name: passName || undefined,
|
||||
prompt,
|
||||
familyId: library.familyId,
|
||||
parentStillId,
|
||||
parentStillId: batch ? library.stillId : parentStillId,
|
||||
chainIndex: index,
|
||||
settings: {
|
||||
kind: 'image',
|
||||
@@ -180,7 +183,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
|
||||
}
|
||||
})
|
||||
job.stillId = still?.id
|
||||
parentStillId = still?.id
|
||||
if (!batch) parentStillId = still?.id
|
||||
await purgeComfyArtifacts({
|
||||
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
|
||||
imageName: uploaded.name,
|
||||
@@ -192,17 +195,19 @@ export async function runEdit(job: Job, params: EditRunParams) {
|
||||
if (!last) {
|
||||
emitChainJob(job, {
|
||||
type: 'checkpoint',
|
||||
message: `Pass ${index + 1} saved`,
|
||||
message: `${unit} ${index + 1} of ${chainTotal} saved`,
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: library.folderLocked
|
||||
})
|
||||
if (!batch) {
|
||||
current = {
|
||||
filename: still?.filename || `pass_${index + 1}.png`,
|
||||
data: buffer,
|
||||
type: 'image/png'
|
||||
}
|
||||
}
|
||||
if (library.stopAfterCurrent === true) break
|
||||
continue
|
||||
}
|
||||
@@ -210,7 +215,9 @@ export async function runEdit(job: Job, params: EditRunParams) {
|
||||
job.status = 'complete'
|
||||
emitChainJob(job, {
|
||||
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,
|
||||
stillId: still?.id,
|
||||
filename: output.filename,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type EditV2RunParams = {
|
||||
referenceStillId?: string
|
||||
loraStack?: import('~/utils/loras').LoraStackItem[]
|
||||
passes?: { prompt: string }[]
|
||||
passMode?: 'batch' | 'chain'
|
||||
}
|
||||
|
||||
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 chainTotal = prompts.length
|
||||
const batch = params.passMode !== 'chain'
|
||||
const unit = batch ? 'Batch' : 'Pass'
|
||||
library.chainTotal = chainTotal
|
||||
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 { composeFillPrompt } = await import('~/utils/imageAspect')
|
||||
let current = params.image || null
|
||||
let startPadded = false
|
||||
let mask = params.mask
|
||||
let parentStillId: string | undefined
|
||||
let uploadedRef: { name: string } | null = null
|
||||
let uploadedMask: { name: string } | null = null
|
||||
|
||||
await ensureComfyLoraNames('image')
|
||||
if (!generate) await assertImageScaleToTotalPixelsNode()
|
||||
if (!generate && params.megapixels > 0) await assertImageScaleToTotalPixelsNode()
|
||||
const krea = engine === 'krea' ? await resolveKreaGenerateAssets() : null
|
||||
|
||||
if (!generate && params.mode === 'compose' && params.reference) {
|
||||
@@ -108,7 +112,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
library.seed = seed
|
||||
library.chainIndex = index
|
||||
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)
|
||||
let prompt = prompts[index]
|
||||
let image = current
|
||||
@@ -151,6 +155,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
}, job.id)
|
||||
}
|
||||
padded = filled.padded
|
||||
startPadded = filled.padded
|
||||
prompt = composeFillPrompt(prompt, padded)
|
||||
if (padded) {
|
||||
emitChainJob(job, {
|
||||
@@ -159,6 +164,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
progress: 7
|
||||
})
|
||||
}
|
||||
} else if (batch && startPadded) {
|
||||
prompt = composeFillPrompt(prompt, true)
|
||||
}
|
||||
|
||||
if (index === 0 && params.mode === 'refine' && mask && !uploadedMask) {
|
||||
@@ -258,7 +265,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
name: passName || undefined,
|
||||
prompt,
|
||||
familyId: library.familyId,
|
||||
parentStillId,
|
||||
parentStillId: batch ? (params.sourceStillId || library.stillId) : parentStillId,
|
||||
chainIndex: index,
|
||||
settings: {
|
||||
kind: 'imagev2',
|
||||
@@ -272,7 +279,8 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
consistencyClip: params.consistencyClip,
|
||||
steps: params.steps,
|
||||
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,
|
||||
strength: params.mode === 'generate' ? undefined : params.strength,
|
||||
width: params.mode === 'generate' ? params.width : undefined,
|
||||
@@ -285,7 +293,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
}
|
||||
})
|
||||
job.stillId = still?.id
|
||||
parentStillId = still?.id
|
||||
if (!batch) parentStillId = still?.id
|
||||
await purgeComfyArtifacts({
|
||||
video: { filename: output.filename, subfolder: output.subfolder, type: output.type },
|
||||
imageName: uploaded?.name,
|
||||
@@ -299,17 +307,19 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
if (!last) {
|
||||
emitChainJob(job, {
|
||||
type: 'checkpoint',
|
||||
message: `Pass ${index + 1} saved`,
|
||||
message: `${unit} ${index + 1} of ${chainTotal} saved`,
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
folderLocked: library.folderLocked
|
||||
})
|
||||
if (!batch) {
|
||||
current = {
|
||||
filename: still?.filename || `pass_${index + 1}.png`,
|
||||
data: buffer,
|
||||
type: 'image/png'
|
||||
}
|
||||
}
|
||||
if (library.stopAfterCurrent === true) break
|
||||
continue
|
||||
}
|
||||
@@ -318,7 +328,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
|
||||
emitChainJob(job, {
|
||||
type: 'complete',
|
||||
message: chainTotal > 1
|
||||
? `${engineLabel} v2 chain finished on Beast`
|
||||
? `${engineLabel} v2 ${batch ? 'batch' : 'chain'} finished on Beast`
|
||||
: `${engineLabel} v2 finished on Beast`,
|
||||
progress: 100,
|
||||
stillId: still?.id,
|
||||
|
||||
@@ -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) {
|
||||
return Object.values(graph).some((node) => {
|
||||
const mask = node.inputs?.mask
|
||||
@@ -401,7 +429,7 @@ function buildKreaItiWorkflow(params: ImageV2BuildParams) {
|
||||
setInput(graph, '15', 'scheduler', 'simple')
|
||||
setInput(graph, '15', 'denoise', denoise)
|
||||
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')
|
||||
|
||||
assertImageV2Graph(graph, params.mode, compose ? params.imageBName : undefined, 'krea')
|
||||
@@ -420,7 +448,7 @@ function buildKreaItiWorkflow(params: ImageV2BuildParams) {
|
||||
steps,
|
||||
cfg,
|
||||
seed: params.seed,
|
||||
megapixels: clampImageScaleMegapixels(params.megapixels ?? 1)
|
||||
megapixels: Number(params.megapixels) > 0 ? clampImageScaleMegapixels(params.megapixels) : 0
|
||||
}))
|
||||
return { graph, workflowFile, loaders, prompt, strength: denoise }
|
||||
}
|
||||
@@ -513,7 +541,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
|
||||
setInput(graph, SCHEDULER, 'steps', steps)
|
||||
setInput(graph, CFG, 'cfg', cfg)
|
||||
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 guider = Object.values(graph).find(node => node.class_type === 'CFGGuider' || node.class_type === 'BasicGuider')
|
||||
@@ -538,7 +566,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
|
||||
steps,
|
||||
cfg,
|
||||
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 }
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface StudioJobPayload {
|
||||
shotLoraStacks?: import('~/utils/loras').LoraStackItem[][]
|
||||
negative?: string
|
||||
passes?: { prompt: string }[]
|
||||
passMode?: 'batch' | 'chain'
|
||||
referenceStillId?: string
|
||||
referenceStillFilename?: string
|
||||
scaleToTotalPixels?: boolean
|
||||
@@ -781,7 +782,9 @@ async function startStudioEditJob(item: StudioJob) {
|
||||
chainIndex: 0,
|
||||
chainStep: 1,
|
||||
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,
|
||||
...persistLoraFields(payload.loraStack || payload.loraName)
|
||||
}
|
||||
@@ -796,6 +799,7 @@ async function startStudioEditJob(item: StudioJob) {
|
||||
mask,
|
||||
prompt: payload.prompt,
|
||||
passes: payload.passes || [],
|
||||
passMode: payload.passMode === 'chain' ? 'chain' : 'batch',
|
||||
negative: payload.negative || '',
|
||||
steps: payload.steps,
|
||||
seed: live.library?.seed || payload.seed,
|
||||
@@ -804,7 +808,7 @@ async function startStudioEditJob(item: StudioJob) {
|
||||
snofsClip: payload.snofsClip ?? 0,
|
||||
consistencyModel: payload.consistencyModel ?? (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,
|
||||
aspect: payload.aspect || 'auto',
|
||||
width: payload.width,
|
||||
@@ -848,7 +852,7 @@ async function startStudioEditJob(item: StudioJob) {
|
||||
chainIndex: 0,
|
||||
chainStep: 1,
|
||||
chainTotal: 1 + passes.length,
|
||||
chainLabel: passes.length ? 'Pass 1' : undefined,
|
||||
chainLabel: passes.length ? (payload.passMode === 'chain' ? 'Pass 1' : 'Batch 1') : undefined,
|
||||
passes,
|
||||
...persistLoraFields(payload.loraStack || payload.loraName)
|
||||
}
|
||||
@@ -860,6 +864,7 @@ async function startStudioEditJob(item: StudioJob) {
|
||||
reference,
|
||||
prompt: payload.prompt,
|
||||
passes,
|
||||
passMode: payload.passMode === 'chain' ? 'chain' : 'batch',
|
||||
negative: payload.negative || '',
|
||||
steps: payload.steps,
|
||||
seed: live.library?.seed || payload.seed,
|
||||
|
||||
@@ -37,6 +37,12 @@ export const IMAGE_SCALE_MP_MIN = 0.01
|
||||
export const IMAGE_SCALE_MP_MAX = 16
|
||||
export const IMAGE_SCALE_MP_STEP = 0.01
|
||||
export const IMAGE_SCALE_MP_DEFAULT = 1
|
||||
export const 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_MAX = 20
|
||||
|
||||
@@ -170,6 +170,7 @@ export type ImageV2PresetSettings = {
|
||||
steps?: number
|
||||
cfg?: number
|
||||
megapixels?: number
|
||||
scaleToTotalPixels?: boolean
|
||||
turbo?: boolean
|
||||
strength?: number
|
||||
width?: number
|
||||
|
||||
@@ -169,7 +169,7 @@ export function stillSettingsRows(still: { width?: number; height?: number; sett
|
||||
if (settings.mode === 'refine' && settings.strength != null) {
|
||||
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) })
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user