Files
ComfyUI-Lora-Manager/vue-widgets/src/components/lora-cycler/LoraCyclerSettingsView.vue
2026-04-01 11:05:38 +08:00

725 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="cycler-settings">
<div class="settings-header">
<h3 class="settings-title">CYCLER SETTINGS</h3>
</div>
<!-- Progress Display -->
<div class="setting-section progress-section">
<div class="progress-display" :class="{ executing: isWorkflowExecuting }">
<div
class="progress-info"
:class="{ disabled: isPauseDisabled }"
@click="handleOpenSelector"
>
<span class="progress-label">{{ isWorkflowExecuting ? 'Using LoRA:' : 'Next LoRA:' }}</span>
<span class="progress-name clickable"
:class="{ disabled: isPauseDisabled, 'no-lora': isNoLora }"
:title="currentLoraFilename">
{{ currentLoraName || 'None' }}
<svg class="selector-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 10l5 5 5-5z"/>
</svg>
</span>
</div>
<div class="progress-counter">
<span class="progress-index">{{ currentIndex }}</span>
<span class="progress-separator">/</span>
<span class="progress-total">{{ totalCount }}</span>
<!-- Repeat progress indicator (only shown when repeatCount > 1) -->
<div v-if="repeatCount > 1" class="repeat-progress">
<div class="repeat-progress-track">
<div
class="repeat-progress-fill"
:style="{ width: `${(repeatUsed / repeatCount) * 100}%` }"
:class="{ 'is-complete': repeatUsed >= repeatCount }"
></div>
</div>
<span class="repeat-progress-text">{{ repeatUsed }}/{{ repeatCount }}</span>
</div>
</div>
</div>
</div>
<!-- Starting Index with Advanced Controls -->
<div class="setting-section">
<div class="index-controls-row">
<!-- Left: Index group -->
<div class="control-group">
<label class="control-group-label">Starting Index</label>
<div class="control-group-content">
<input
type="number"
class="index-input"
:min="1"
:max="totalCount || 1"
:value="currentIndex"
:disabled="totalCount === 0"
@input="onIndexInput"
@blur="onIndexBlur"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
/>
<span class="index-hint">/ {{ totalCount || 1 }}</span>
</div>
</div>
<!-- Right: Repeat group -->
<div class="control-group">
<label class="control-group-label">Repeat</label>
<div class="control-group-content">
<input
type="number"
class="repeat-input"
min="1"
max="99"
:value="repeatCount"
@input="onRepeatInput"
@blur="onRepeatBlur"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
title="Each LoRA will be used this many times before moving to the next"
/>
<span class="repeat-suffix">×</span>
</div>
</div>
<!-- Action buttons -->
<div class="action-buttons">
<button
class="control-btn"
:class="{ active: isPaused }"
:disabled="isPauseDisabled"
@click="$emit('toggle-pause')"
:title="isPauseDisabled ? 'Cannot pause while prompts are queued' : (isPaused ? 'Continue iteration' : 'Pause iteration')"
>
<svg v-if="isPaused" viewBox="0 0 24 24" fill="currentColor" class="control-icon">
<path d="M8 5v14l11-7z"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor" class="control-icon">
<path d="M6 4h4v16H6zm8 0h4v16h-4z"/>
</svg>
</button>
<button
class="control-btn"
@click="$emit('reset-index')"
title="Reset to index 1"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="control-icon">
<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Model Strength -->
<div class="setting-section">
<label class="setting-label">Model Strength</label>
<div class="slider-container">
<SingleSlider
:min="-10"
:max="10"
:value="modelStrength"
:step="0.1"
:default-range="{ min: 0.5, max: 1.5 }"
@update:value="$emit('update:modelStrength', $event)"
/>
</div>
</div>
<!-- Preset Strength Scale -->
<div class="setting-section">
<div class="section-header-with-toggle">
<label class="setting-label">
Preset Strength Scale
</label>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': usePresetStrength }"
@click="$emit('update:usePresetStrength', !usePresetStrength)"
role="switch"
:aria-checked="usePresetStrength"
title="Use scaled preset strength when enabled"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</div>
<div class="slider-container" :class="{ 'slider-container--disabled': !usePresetStrength }">
<SingleSlider
:min="0"
:max="2"
:value="presetStrengthScale"
:step="0.1"
:default-range="{ min: 0.5, max: 1.0 }"
:disabled="!usePresetStrength"
@update:value="$emit('update:presetStrengthScale', $event)"
/>
</div>
</div>
<!-- Clip Strength -->
<div class="setting-section">
<div class="section-header-with-toggle">
<label class="setting-label">
Clip Strength - {{ useCustomClipRange ? 'Custom Value' : 'Use Model Strength' }}
</label>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': useCustomClipRange }"
@click="$emit('update:useCustomClipRange', !useCustomClipRange)"
role="switch"
:aria-checked="useCustomClipRange"
title="Use custom clip strength when enabled, otherwise use model strength"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</div>
<div class="slider-container" :class="{ 'slider-container--disabled': isClipStrengthDisabled }">
<SingleSlider
:min="-10"
:max="10"
:value="clipStrength"
:step="0.1"
:default-range="{ min: 0.5, max: 1.5 }"
:disabled="isClipStrengthDisabled"
@update:value="$emit('update:clipStrength', $event)"
/>
</div>
</div>
<!-- Include No LoRA Toggle -->
<div class="setting-section">
<div class="section-header-with-toggle">
<label class="setting-label">
Add "No LoRA" step
</label>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': includeNoLora }"
@click="$emit('update:includeNoLora', !includeNoLora)"
role="switch"
:aria-checked="includeNoLora"
title="Add an iteration without LoRA for comparison"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SingleSlider from '../shared/SingleSlider.vue'
const props = defineProps<{
currentIndex: number
totalCount: number
currentLoraName: string
currentLoraFilename: string
modelStrength: number
clipStrength: number
useCustomClipRange: boolean
usePresetStrength: boolean
presetStrengthScale: number
isClipStrengthDisabled: boolean
repeatCount: number
repeatUsed: number
isPaused: boolean
isPauseDisabled: boolean
isWorkflowExecuting: boolean
executingRepeatStep: number
includeNoLora: boolean
isNoLora?: boolean
}>()
const emit = defineEmits<{
'update:currentIndex': [value: number]
'update:modelStrength': [value: number]
'update:clipStrength': [value: number]
'update:useCustomClipRange': [value: boolean]
'update:usePresetStrength': [value: boolean]
'update:presetStrengthScale': [value: number]
'update:repeatCount': [value: number]
'update:includeNoLora': [value: boolean]
'toggle-pause': []
'reset-index': []
'open-lora-selector': []
}>()
// Temporary value for input while typing
const tempIndex = ref<string>('')
const tempRepeat = ref<string>('')
const handleOpenSelector = () => {
if (props.isPauseDisabled) {
return
}
emit('open-lora-selector')
}
const onIndexInput = (event: Event) => {
const input = event.target as HTMLInputElement
tempIndex.value = input.value
}
const onIndexBlur = (event: Event) => {
const input = event.target as HTMLInputElement
const value = parseInt(input.value, 10)
if (!isNaN(value)) {
const clampedValue = Math.max(1, Math.min(value, props.totalCount || 1))
emit('update:currentIndex', clampedValue)
input.value = clampedValue.toString()
} else {
input.value = props.currentIndex.toString()
}
tempIndex.value = ''
}
const onRepeatInput = (event: Event) => {
const input = event.target as HTMLInputElement
tempRepeat.value = input.value
}
const onRepeatBlur = (event: Event) => {
const input = event.target as HTMLInputElement
const value = parseInt(input.value, 10)
if (!isNaN(value)) {
const clampedValue = Math.max(1, Math.min(value, 99))
emit('update:repeatCount', clampedValue)
input.value = clampedValue.toString()
} else {
input.value = props.repeatCount.toString()
}
tempRepeat.value = ''
}
</script>
<style scoped>
.cycler-settings {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #e4e4e7;
}
.settings-header {
margin-bottom: 8px;
}
.settings-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
margin: 0;
text-transform: uppercase;
}
.setting-section {
margin-bottom: 8px;
}
.setting-label {
font-size: 13px;
font-weight: 500;
color: rgba(226, 232, 240, 0.8);
display: block;
margin-bottom: 6px;
}
/* Progress Display */
.progress-section {
margin-bottom: 12px;
}
.progress-display {
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px;
padding: 8px 10px;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.3s ease;
}
.progress-display.executing {
border-color: rgba(66, 153, 225, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { border-color: rgba(66, 153, 225, 0.3); }
50% { border-color: rgba(66, 153, 225, 0.7); }
}
.progress-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.progress-label {
font-size: 10px;
font-weight: 500;
color: rgba(226, 232, 240, 0.5);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.progress-name {
font-size: 13px;
font-weight: 500;
color: rgba(191, 219, 254, 1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-name.clickable {
cursor: pointer;
padding: 2px 6px;
margin: -2px -6px;
border-radius: 4px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 4px;
}
.progress-name.clickable:hover:not(.disabled) {
background: rgba(66, 153, 225, 0.2);
color: rgba(191, 219, 254, 1);
}
.progress-name.no-lora {
font-style: italic;
color: rgba(226, 232, 240, 0.6);
}
.progress-name.clickable.no-lora:hover:not(.disabled) {
background: rgba(160, 174, 192, 0.2);
color: rgba(226, 232, 240, 0.8);
}
.progress-name.clickable.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.progress-info.disabled {
cursor: not-allowed;
}
.selector-icon {
width: 16px;
height: 16px;
opacity: 0.5;
flex-shrink: 0;
}
.progress-name.clickable:hover .selector-icon {
opacity: 0.8;
}
.progress-counter {
display: flex;
align-items: center;
gap: 4px;
padding-left: 12px;
flex-shrink: 0;
}
.progress-index {
font-size: 18px;
font-weight: 600;
color: rgba(66, 153, 225, 1);
font-family: 'SF Mono', 'Roboto Mono', monospace;
min-width: 4ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
.progress-separator {
font-size: 14px;
color: rgba(226, 232, 240, 0.4);
margin: 0 2px;
}
.progress-total {
font-size: 14px;
font-weight: 500;
color: rgba(226, 232, 240, 0.6);
font-family: 'SF Mono', 'Roboto Mono', monospace;
min-width: 4ch;
text-align: left;
font-variant-numeric: tabular-nums;
}
/* Repeat Progress */
.repeat-progress {
display: flex;
align-items: center;
gap: 6px;
margin-left: 8px;
padding: 2px 6px;
background: rgba(26, 32, 44, 0.6);
border: 1px solid rgba(226, 232, 240, 0.1);
border-radius: 4px;
}
.repeat-progress-track {
width: 32px;
height: 4px;
background: rgba(226, 232, 240, 0.15);
border-radius: 2px;
overflow: hidden;
}
.repeat-progress-fill {
height: 100%;
background: linear-gradient(90deg, #f59e0b, #fbbf24);
border-radius: 2px;
transition: width 0.3s ease;
}
.repeat-progress-fill.is-complete {
background: linear-gradient(90deg, #10b981, #34d399);
}
.repeat-progress-text {
font-size: 10px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
color: rgba(253, 230, 138, 0.9);
min-width: 3ch;
font-variant-numeric: tabular-nums;
}
/* Index Controls Row - Grouped Layout */
.index-controls-row {
display: flex;
align-items: flex-end;
gap: 16px;
}
/* Control Group */
.control-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.control-group-label {
font-size: 11px;
font-weight: 500;
color: rgba(226, 232, 240, 0.5);
text-transform: uppercase;
letter-spacing: 0.03em;
line-height: 1;
}
.control-group-content {
display: flex;
align-items: baseline;
gap: 4px;
height: 32px;
}
.index-input {
width: 50px;
height: 32px;
padding: 0 8px;
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px;
color: #e4e4e7;
font-size: 13px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
line-height: 32px;
box-sizing: border-box;
}
.index-input:focus {
outline: none;
border-color: rgba(66, 153, 225, 0.6);
}
.index-input:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.index-hint {
font-size: 12px;
color: rgba(226, 232, 240, 0.4);
font-variant-numeric: tabular-nums;
line-height: 32px;
}
/* Repeat Controls */
.repeat-input {
width: 50px;
height: 32px;
padding: 0 6px;
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px;
color: #e4e4e7;
font-size: 13px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
text-align: center;
line-height: 32px;
box-sizing: border-box;
}
.repeat-input:focus {
outline: none;
border-color: rgba(66, 153, 225, 0.6);
}
.repeat-suffix {
font-size: 13px;
color: rgba(226, 232, 240, 0.4);
font-weight: 500;
line-height: 32px;
}
/* Action Buttons */
.action-buttons {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
/* Control Buttons */
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: rgba(226, 232, 240, 0.6);
cursor: pointer;
transition: all 0.2s;
}
.control-btn:hover:not(:disabled) {
background: rgba(66, 153, 225, 0.2);
border-color: rgba(66, 153, 225, 0.4);
color: rgba(191, 219, 254, 1);
}
.control-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.control-btn.active {
background: rgba(245, 158, 11, 0.2);
border-color: rgba(245, 158, 11, 0.5);
color: rgba(253, 230, 138, 1);
}
.control-btn.active:hover {
background: rgba(245, 158, 11, 0.3);
border-color: rgba(245, 158, 11, 0.6);
}
.control-icon {
width: 14px;
height: 14px;
}
/* Slider Container */
.slider-container {
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px;
padding: 6px;
}
.slider-container--disabled {
opacity: 0.5;
pointer-events: none;
}
.section-header-with-toggle {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section-header-with-toggle .setting-label {
margin-bottom: 4px;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.toggle-switch__track {
position: absolute;
inset: 0;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--border-color, #444);
border-radius: 10px;
transition: all 0.2s;
}
.toggle-switch--active .toggle-switch__track {
background: rgba(66, 153, 225, 0.3);
border-color: rgba(66, 153, 225, 0.6);
}
.toggle-switch__thumb {
position: absolute;
top: 3px;
left: 2px;
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
transition: all 0.2s;
opacity: 0.6;
}
.toggle-switch--active .toggle-switch__thumb {
transform: translateX(16px);
background: #4299e1;
opacity: 1;
}
.toggle-switch:hover .toggle-switch__thumb {
opacity: 1;
}
</style>